From 7c3c0ebb4b114da700b15d5e577420a280e6bafe Mon Sep 17 00:00:00 2001 From: MengMengCode Date: Sun, 9 Aug 2026 05:33:21 +0800 Subject: [PATCH] Initial --- .gitignore | 45 + build/deploy.py | 52 + build/smoke_verify.py | 126 + build/ssh_exec.py | 31 + cmd/vocat/main.go | 581 ++++ cmd/vocat/main_test.go | 223 ++ deploy/vocat.service | 40 + go.mod | 23 + go.sum | 63 + internal/auth/service.go | 291 ++ internal/auth/service_test.go | 98 + internal/config/config.go | 232 ++ internal/config/config_test.go | 100 + internal/device/controls.go | 371 ++ internal/device/controls_test.go | 78 + internal/device/data.go | 292 ++ internal/device/data_linux.go | 99 + internal/device/data_other.go | 20 + internal/device/data_test.go | 109 + internal/device/es9p.go | 317 ++ internal/device/es9p_test.go | 143 + internal/device/esim.go | 887 +++++ internal/device/esim_delete.go | 113 + internal/device/esim_der.go | 117 + internal/device/esim_der_test.go | 95 + internal/device/esim_disable.go | 98 + internal/device/esim_download.go | 303 ++ internal/device/esim_lpa.go | 599 ++++ internal/device/esim_lpa_test.go | 157 + internal/device/esim_rename.go | 82 + internal/device/esim_test.go | 321 ++ internal/device/manager.go | 531 +++ internal/device/manager_test.go | 141 + internal/device/phone.go | 336 ++ internal/device/phone_test.go | 88 + internal/device/region.go | 73 + internal/device/region_test.go | 116 + internal/device/scan.go | 131 + internal/device/scan_ussd_test.go | 109 + internal/device/sms.go | 444 +++ internal/device/sms_pdu.go | 944 +++++ internal/device/sms_pdu_test.go | 265 ++ internal/device/sms_test.go | 360 ++ internal/device/snapshot.go | 351 ++ internal/device/test_helpers_test.go | 232 ++ internal/device/types.go | 232 ++ internal/i18n/i18n.go | 145 + internal/i18n/i18n_test.go | 49 + internal/loghub/hub.go | 278 ++ internal/loghub/hub_test.go | 49 + internal/modem/default_discovery_linux.go | 7 + internal/modem/default_discovery_stub.go | 7 + internal/modem/discovery.go | 307 ++ internal/modem/discovery_common_test.go | 14 + internal/modem/discovery_test.go | 158 + internal/modem/serial.go | 47 + internal/modem/session.go | 572 ++++ internal/modem/session_test.go | 428 +++ internal/modem/types.go | 119 + internal/proxy/probe.go | 160 + internal/server/access_control.go | 262 ++ internal/server/at_guard_test.go | 46 + internal/server/audit.go | 68 + internal/server/device_api.go | 1517 +++++++++ internal/server/device_features_api.go | 253 ++ internal/server/device_features_api_test.go | 343 ++ internal/server/device_vowifi_test.go | 201 ++ internal/server/e911_api.go | 253 ++ internal/server/errors.go | 30 + internal/server/esim_api.go | 467 +++ internal/server/general_api.go | 410 +++ internal/server/logging_api.go | 161 + internal/server/login_rate_limit.go | 90 + internal/server/proxy_api.go | 563 +++ internal/server/proxy_binding_test.go | 129 + internal/server/region_test.go | 241 ++ internal/server/security_settings_test.go | 235 ++ internal/server/server.go | 569 ++++ internal/server/server_test.go | 373 ++ internal/server/settings_api.go | 1333 ++++++++ internal/server/settings_api_test.go | 530 +++ internal/server/sms_api.go | 662 ++++ internal/server/sms_api_test.go | 115 + internal/store/devices.go | 578 ++++ internal/store/domain_test.go | 595 ++++ internal/store/events.go | 306 ++ internal/store/migrations.go | 325 ++ internal/store/models.go | 608 ++++ internal/store/phone.go | 79 + internal/store/phone_test.go | 47 + internal/store/proxy.go | 561 +++ internal/store/settings.go | 586 ++++ internal/store/sms.go | 550 +++ internal/store/store.go | 326 ++ internal/store/store_test.go | 90 + internal/vowifi/ec20_adapter.go | 1373 ++++++++ internal/vowifi/ec20_adapter_test.go | 588 ++++ internal/vowifi/ike/auth.go | 368 ++ internal/vowifi/ike/child.go | 336 ++ internal/vowifi/ike/crypto.go | 376 ++ internal/vowifi/ike/crypto_test.go | 115 + internal/vowifi/ike/doc.go | 3 + internal/vowifi/ike/eap.go | 651 ++++ internal/vowifi/ike/eap_test.go | 224 ++ internal/vowifi/ike/epdg_resolver.go | 144 + internal/vowifi/ike/epdg_resolver_test.go | 56 + internal/vowifi/ike/esp.go | 522 +++ internal/vowifi/ike/esp_test.go | 412 +++ internal/vowifi/ike/installer_linux.go | 358 ++ internal/vowifi/ike/installer_other.go | 18 + internal/vowifi/ike/provider.go | 885 +++++ internal/vowifi/ike/provider_name_test.go | 44 + internal/vowifi/ike/provider_test.go | 236 ++ internal/vowifi/ike/relay.go | 209 ++ internal/vowifi/ike/relay_test.go | 180 + internal/vowifi/ike/transport.go | 934 +++++ internal/vowifi/ike/transport_test.go | 360 ++ internal/vowifi/ike/userspace_linux.go | 620 ++++ internal/vowifi/ike/userspace_linux_test.go | 209 ++ internal/vowifi/ike/wire.go | 421 +++ internal/vowifi/ike/wire_auth_test.go | 185 + internal/vowifi/ims/digest.go | 320 ++ internal/vowifi/ims/digest_test.go | 157 + internal/vowifi/ims/message.go | 284 ++ internal/vowifi/ims/message_test.go | 81 + internal/vowifi/ims/provider.go | 1228 +++++++ internal/vowifi/ims/provider_test.go | 493 +++ internal/vowifi/ims/security.go | 747 ++++ internal/vowifi/ims/security_linux.go | 89 + internal/vowifi/ims/security_linux_test.go | 62 + internal/vowifi/ims/security_other.go | 18 + internal/vowifi/ims/security_provider_test.go | 570 ++++ internal/vowifi/ims/security_test.go | 234 ++ internal/vowifi/ims/sms_runtime.go | 723 ++++ internal/vowifi/ims/sms_runtime_test.go | 372 ++ internal/vowifi/integration/at.go | 95 + internal/vowifi/integration/at_test.go | 89 + internal/vowifi/integration/store.go | 225 ++ internal/vowifi/integration/store_test.go | 201 ++ internal/vowifi/orchestrator.go | 811 +++++ internal/vowifi/orchestrator_test.go | 907 +++++ internal/vowifi/phone.go | 105 + internal/vowifi/phone_test.go | 160 + internal/vowifi/runtime/manager.go | 369 ++ internal/vowifi/runtime/manager_test.go | 255 ++ internal/vowifi/types.go | 445 +++ web/embed.go | 20 + web/index.html | 15 + web/package-lock.json | 3028 +++++++++++++++++ web/package.json | 38 + web/postcss.config.js | 6 + web/public/ec20.png | Bin 0 -> 208937 bytes web/public/favicon.svg | 1 + web/public/theme-init.js | 9 + web/src/App.tsx | 131 + web/src/api.ts | 175 + web/src/components/DeviceCard.tsx | 90 + web/src/components/Disclaimer.tsx | 226 ++ web/src/components/EChart.tsx | 27 + web/src/components/devices/AtLogEntry.tsx | 52 + web/src/components/devices/CandidateRow.tsx | 45 + .../components/devices/CardPolicyPanel.tsx | 87 + .../devices/CarrierWebsheetDialog.tsx | 128 + .../components/devices/DeleteProfileModal.tsx | 39 + .../components/devices/DeviceAddDialog.tsx | 166 + web/src/components/devices/DeviceAtTab.tsx | 133 + .../components/devices/DeviceConfigTab.tsx | 118 + .../components/devices/DeviceDetailHeader.tsx | 75 + .../devices/DeviceDetailSkeleton.tsx | 12 + web/src/components/devices/DeviceEsimTab.tsx | 552 +++ .../components/devices/DeviceListItemCard.tsx | 43 + .../components/devices/DeviceListPanel.tsx | 113 + .../components/devices/DeviceOverviewTab.tsx | 64 + web/src/components/devices/DeviceUssdTab.tsx | 165 + .../devices/DiscoveredDeviceRow.tsx | 45 + .../devices/EsimCardPolicyInline.tsx | 94 + web/src/components/devices/EsimChipHeader.tsx | 60 + .../components/devices/EsimDownloadForm.tsx | 88 + web/src/components/devices/EsimEuiccGroup.tsx | 161 + .../components/devices/EsimLoadingHero.tsx | 12 + .../devices/EsimNotificationsModal.tsx | 83 + web/src/components/devices/EsimProfileRow.tsx | 105 + web/src/components/devices/FieldRow.tsx | 43 + .../devices/OperatorSelectionDialog.tsx | 255 ++ .../devices/OverviewNetworkCard.tsx | 123 + .../devices/OverviewNetworkPanel.tsx | 42 + .../components/devices/OverviewSimPanel.tsx | 64 + .../components/devices/OverviewVowifiCard.tsx | 109 + .../components/devices/PolicySwitchCard.tsx | 51 + web/src/components/devices/UssdLogEntry.tsx | 51 + web/src/components/devices/atCommands.ts | 68 + web/src/components/devices/deviceActions.ts | 27 + web/src/components/devices/shared.ts | 323 ++ web/src/components/devices/types.ts | 110 + .../devices/useCardPolicyToggles.ts | 76 + web/src/components/logs/LogRetentionCard.tsx | 130 + .../components/proxy/DeviceBindingsDialog.tsx | 78 + web/src/components/proxy/UpstreamDialog.tsx | 137 + web/src/components/proxy/UpstreamSection.tsx | 98 + web/src/components/proxy/formUi.tsx | 43 + web/src/components/proxy/shared.ts | 46 + web/src/components/settings/BotTabs.tsx | 74 + web/src/components/settings/Cards.tsx | 188 + .../components/settings/NetworkAccessCard.tsx | 173 + web/src/components/settings/PushTabs.tsx | 257 ++ web/src/components/settings/controls.tsx | 258 ++ web/src/components/settings/model.ts | 250 ++ .../components/shell/AuthenticatedShell.tsx | 195 ++ web/src/components/shell/BrandLogo.tsx | 15 + .../components/shell/UnauthenticatedShell.tsx | 22 + web/src/components/sms/ContactList.tsx | 135 + web/src/components/sms/NewSmsModal.tsx | 91 + web/src/components/sms/ThreadPanel.tsx | 237 ++ web/src/components/sms/smsApi.ts | 86 + web/src/components/sms/smsText.ts | 160 + web/src/components/ui/Button.tsx | 96 + web/src/components/ui/Drawer.tsx | 43 + web/src/components/ui/EmptyState.tsx | 25 + web/src/components/ui/ErrorBoundary.tsx | 46 + web/src/components/ui/ErrorState.tsx | 60 + web/src/components/ui/Input.tsx | 56 + web/src/components/ui/LanguageSwitch.tsx | 23 + web/src/components/ui/ListSkeleton.tsx | 16 + web/src/components/ui/LoadingScreen.tsx | 34 + web/src/components/ui/MessageBox.tsx | 78 + web/src/components/ui/Modal.tsx | 80 + web/src/components/ui/PageHeader.tsx | 16 + web/src/components/ui/RefreshButton.tsx | 21 + web/src/components/ui/Select.tsx | 149 + web/src/components/ui/Spinner.tsx | 14 + web/src/components/ui/StatusDot.tsx | 36 + web/src/components/ui/Switch.tsx | 40 + web/src/components/ui/SwitchDark.tsx | 17 + web/src/components/ui/Tabs.tsx | 54 + web/src/components/ui/Tag.tsx | 27 + web/src/components/ui/Tooltip.tsx | 32 + web/src/components/ui/index.ts | 25 + web/src/components/ui/message.tsx | 97 + web/src/index.css | 3 + web/src/lib/carrier.ts | 58 + web/src/lib/i18n-en.ts | 825 +++++ web/src/lib/i18n.tsx | 88 + web/src/lib/mccmnc.json | 1 + web/src/lib/usePolling.ts | 42 + web/src/lib/utils.ts | 73 + web/src/main.tsx | 25 + web/src/pages/DashboardPage.tsx | 77 + web/src/pages/DevicesPage.tsx | 723 ++++ web/src/pages/LoginPage.tsx | 106 + web/src/pages/LogsPage.tsx | 316 ++ web/src/pages/ProxyPage.tsx | 265 ++ web/src/pages/SettingsPage.tsx | 351 ++ web/src/pages/SmsPage.tsx | 788 +++++ web/src/store/auth.tsx | 81 + web/src/types.ts | 386 +++ web/src/vocat.css | 427 +++ web/tailwind.config.js | 77 + web/tsconfig.app.json | 20 + web/tsconfig.json | 7 + web/tsconfig.node.json | 10 + web/vite.config.ts | 27 + 261 files changed, 61250 insertions(+) create mode 100644 .gitignore create mode 100644 build/deploy.py create mode 100644 build/smoke_verify.py create mode 100644 build/ssh_exec.py create mode 100644 cmd/vocat/main.go create mode 100644 cmd/vocat/main_test.go create mode 100644 deploy/vocat.service create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/auth/service.go create mode 100644 internal/auth/service_test.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/device/controls.go create mode 100644 internal/device/controls_test.go create mode 100644 internal/device/data.go create mode 100644 internal/device/data_linux.go create mode 100644 internal/device/data_other.go create mode 100644 internal/device/data_test.go create mode 100644 internal/device/es9p.go create mode 100644 internal/device/es9p_test.go create mode 100644 internal/device/esim.go create mode 100644 internal/device/esim_delete.go create mode 100644 internal/device/esim_der.go create mode 100644 internal/device/esim_der_test.go create mode 100644 internal/device/esim_disable.go create mode 100644 internal/device/esim_download.go create mode 100644 internal/device/esim_lpa.go create mode 100644 internal/device/esim_lpa_test.go create mode 100644 internal/device/esim_rename.go create mode 100644 internal/device/esim_test.go create mode 100644 internal/device/manager.go create mode 100644 internal/device/manager_test.go create mode 100644 internal/device/phone.go create mode 100644 internal/device/phone_test.go create mode 100644 internal/device/region.go create mode 100644 internal/device/region_test.go create mode 100644 internal/device/scan.go create mode 100644 internal/device/scan_ussd_test.go create mode 100644 internal/device/sms.go create mode 100644 internal/device/sms_pdu.go create mode 100644 internal/device/sms_pdu_test.go create mode 100644 internal/device/sms_test.go create mode 100644 internal/device/snapshot.go create mode 100644 internal/device/test_helpers_test.go create mode 100644 internal/device/types.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/loghub/hub.go create mode 100644 internal/loghub/hub_test.go create mode 100644 internal/modem/default_discovery_linux.go create mode 100644 internal/modem/default_discovery_stub.go create mode 100644 internal/modem/discovery.go create mode 100644 internal/modem/discovery_common_test.go create mode 100644 internal/modem/discovery_test.go create mode 100644 internal/modem/serial.go create mode 100644 internal/modem/session.go create mode 100644 internal/modem/session_test.go create mode 100644 internal/modem/types.go create mode 100644 internal/proxy/probe.go create mode 100644 internal/server/access_control.go create mode 100644 internal/server/at_guard_test.go create mode 100644 internal/server/audit.go create mode 100644 internal/server/device_api.go create mode 100644 internal/server/device_features_api.go create mode 100644 internal/server/device_features_api_test.go create mode 100644 internal/server/device_vowifi_test.go create mode 100644 internal/server/e911_api.go create mode 100644 internal/server/errors.go create mode 100644 internal/server/esim_api.go create mode 100644 internal/server/general_api.go create mode 100644 internal/server/logging_api.go create mode 100644 internal/server/login_rate_limit.go create mode 100644 internal/server/proxy_api.go create mode 100644 internal/server/proxy_binding_test.go create mode 100644 internal/server/region_test.go create mode 100644 internal/server/security_settings_test.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/settings_api.go create mode 100644 internal/server/settings_api_test.go create mode 100644 internal/server/sms_api.go create mode 100644 internal/server/sms_api_test.go create mode 100644 internal/store/devices.go create mode 100644 internal/store/domain_test.go create mode 100644 internal/store/events.go create mode 100644 internal/store/migrations.go create mode 100644 internal/store/models.go create mode 100644 internal/store/phone.go create mode 100644 internal/store/phone_test.go create mode 100644 internal/store/proxy.go create mode 100644 internal/store/settings.go create mode 100644 internal/store/sms.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/vowifi/ec20_adapter.go create mode 100644 internal/vowifi/ec20_adapter_test.go create mode 100644 internal/vowifi/ike/auth.go create mode 100644 internal/vowifi/ike/child.go create mode 100644 internal/vowifi/ike/crypto.go create mode 100644 internal/vowifi/ike/crypto_test.go create mode 100644 internal/vowifi/ike/doc.go create mode 100644 internal/vowifi/ike/eap.go create mode 100644 internal/vowifi/ike/eap_test.go create mode 100644 internal/vowifi/ike/epdg_resolver.go create mode 100644 internal/vowifi/ike/epdg_resolver_test.go create mode 100644 internal/vowifi/ike/esp.go create mode 100644 internal/vowifi/ike/esp_test.go create mode 100644 internal/vowifi/ike/installer_linux.go create mode 100644 internal/vowifi/ike/installer_other.go create mode 100644 internal/vowifi/ike/provider.go create mode 100644 internal/vowifi/ike/provider_name_test.go create mode 100644 internal/vowifi/ike/provider_test.go create mode 100644 internal/vowifi/ike/relay.go create mode 100644 internal/vowifi/ike/relay_test.go create mode 100644 internal/vowifi/ike/transport.go create mode 100644 internal/vowifi/ike/transport_test.go create mode 100644 internal/vowifi/ike/userspace_linux.go create mode 100644 internal/vowifi/ike/userspace_linux_test.go create mode 100644 internal/vowifi/ike/wire.go create mode 100644 internal/vowifi/ike/wire_auth_test.go create mode 100644 internal/vowifi/ims/digest.go create mode 100644 internal/vowifi/ims/digest_test.go create mode 100644 internal/vowifi/ims/message.go create mode 100644 internal/vowifi/ims/message_test.go create mode 100644 internal/vowifi/ims/provider.go create mode 100644 internal/vowifi/ims/provider_test.go create mode 100644 internal/vowifi/ims/security.go create mode 100644 internal/vowifi/ims/security_linux.go create mode 100644 internal/vowifi/ims/security_linux_test.go create mode 100644 internal/vowifi/ims/security_other.go create mode 100644 internal/vowifi/ims/security_provider_test.go create mode 100644 internal/vowifi/ims/security_test.go create mode 100644 internal/vowifi/ims/sms_runtime.go create mode 100644 internal/vowifi/ims/sms_runtime_test.go create mode 100644 internal/vowifi/integration/at.go create mode 100644 internal/vowifi/integration/at_test.go create mode 100644 internal/vowifi/integration/store.go create mode 100644 internal/vowifi/integration/store_test.go create mode 100644 internal/vowifi/orchestrator.go create mode 100644 internal/vowifi/orchestrator_test.go create mode 100644 internal/vowifi/phone.go create mode 100644 internal/vowifi/phone_test.go create mode 100644 internal/vowifi/runtime/manager.go create mode 100644 internal/vowifi/runtime/manager_test.go create mode 100644 internal/vowifi/types.go create mode 100644 web/embed.go create mode 100644 web/index.html create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/postcss.config.js create mode 100644 web/public/ec20.png create mode 100644 web/public/favicon.svg create mode 100644 web/public/theme-init.js create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/components/DeviceCard.tsx create mode 100644 web/src/components/Disclaimer.tsx create mode 100644 web/src/components/EChart.tsx create mode 100644 web/src/components/devices/AtLogEntry.tsx create mode 100644 web/src/components/devices/CandidateRow.tsx create mode 100644 web/src/components/devices/CardPolicyPanel.tsx create mode 100644 web/src/components/devices/CarrierWebsheetDialog.tsx create mode 100644 web/src/components/devices/DeleteProfileModal.tsx create mode 100644 web/src/components/devices/DeviceAddDialog.tsx create mode 100644 web/src/components/devices/DeviceAtTab.tsx create mode 100644 web/src/components/devices/DeviceConfigTab.tsx create mode 100644 web/src/components/devices/DeviceDetailHeader.tsx create mode 100644 web/src/components/devices/DeviceDetailSkeleton.tsx create mode 100644 web/src/components/devices/DeviceEsimTab.tsx create mode 100644 web/src/components/devices/DeviceListItemCard.tsx create mode 100644 web/src/components/devices/DeviceListPanel.tsx create mode 100644 web/src/components/devices/DeviceOverviewTab.tsx create mode 100644 web/src/components/devices/DeviceUssdTab.tsx create mode 100644 web/src/components/devices/DiscoveredDeviceRow.tsx create mode 100644 web/src/components/devices/EsimCardPolicyInline.tsx create mode 100644 web/src/components/devices/EsimChipHeader.tsx create mode 100644 web/src/components/devices/EsimDownloadForm.tsx create mode 100644 web/src/components/devices/EsimEuiccGroup.tsx create mode 100644 web/src/components/devices/EsimLoadingHero.tsx create mode 100644 web/src/components/devices/EsimNotificationsModal.tsx create mode 100644 web/src/components/devices/EsimProfileRow.tsx create mode 100644 web/src/components/devices/FieldRow.tsx create mode 100644 web/src/components/devices/OperatorSelectionDialog.tsx create mode 100644 web/src/components/devices/OverviewNetworkCard.tsx create mode 100644 web/src/components/devices/OverviewNetworkPanel.tsx create mode 100644 web/src/components/devices/OverviewSimPanel.tsx create mode 100644 web/src/components/devices/OverviewVowifiCard.tsx create mode 100644 web/src/components/devices/PolicySwitchCard.tsx create mode 100644 web/src/components/devices/UssdLogEntry.tsx create mode 100644 web/src/components/devices/atCommands.ts create mode 100644 web/src/components/devices/deviceActions.ts create mode 100644 web/src/components/devices/shared.ts create mode 100644 web/src/components/devices/types.ts create mode 100644 web/src/components/devices/useCardPolicyToggles.ts create mode 100644 web/src/components/logs/LogRetentionCard.tsx create mode 100644 web/src/components/proxy/DeviceBindingsDialog.tsx create mode 100644 web/src/components/proxy/UpstreamDialog.tsx create mode 100644 web/src/components/proxy/UpstreamSection.tsx create mode 100644 web/src/components/proxy/formUi.tsx create mode 100644 web/src/components/proxy/shared.ts create mode 100644 web/src/components/settings/BotTabs.tsx create mode 100644 web/src/components/settings/Cards.tsx create mode 100644 web/src/components/settings/NetworkAccessCard.tsx create mode 100644 web/src/components/settings/PushTabs.tsx create mode 100644 web/src/components/settings/controls.tsx create mode 100644 web/src/components/settings/model.ts create mode 100644 web/src/components/shell/AuthenticatedShell.tsx create mode 100644 web/src/components/shell/BrandLogo.tsx create mode 100644 web/src/components/shell/UnauthenticatedShell.tsx create mode 100644 web/src/components/sms/ContactList.tsx create mode 100644 web/src/components/sms/NewSmsModal.tsx create mode 100644 web/src/components/sms/ThreadPanel.tsx create mode 100644 web/src/components/sms/smsApi.ts create mode 100644 web/src/components/sms/smsText.ts create mode 100644 web/src/components/ui/Button.tsx create mode 100644 web/src/components/ui/Drawer.tsx create mode 100644 web/src/components/ui/EmptyState.tsx create mode 100644 web/src/components/ui/ErrorBoundary.tsx create mode 100644 web/src/components/ui/ErrorState.tsx create mode 100644 web/src/components/ui/Input.tsx create mode 100644 web/src/components/ui/LanguageSwitch.tsx create mode 100644 web/src/components/ui/ListSkeleton.tsx create mode 100644 web/src/components/ui/LoadingScreen.tsx create mode 100644 web/src/components/ui/MessageBox.tsx create mode 100644 web/src/components/ui/Modal.tsx create mode 100644 web/src/components/ui/PageHeader.tsx create mode 100644 web/src/components/ui/RefreshButton.tsx create mode 100644 web/src/components/ui/Select.tsx create mode 100644 web/src/components/ui/Spinner.tsx create mode 100644 web/src/components/ui/StatusDot.tsx create mode 100644 web/src/components/ui/Switch.tsx create mode 100644 web/src/components/ui/SwitchDark.tsx create mode 100644 web/src/components/ui/Tabs.tsx create mode 100644 web/src/components/ui/Tag.tsx create mode 100644 web/src/components/ui/Tooltip.tsx create mode 100644 web/src/components/ui/index.ts create mode 100644 web/src/components/ui/message.tsx create mode 100644 web/src/index.css create mode 100644 web/src/lib/carrier.ts create mode 100644 web/src/lib/i18n-en.ts create mode 100644 web/src/lib/i18n.tsx create mode 100644 web/src/lib/mccmnc.json create mode 100644 web/src/lib/usePolling.ts create mode 100644 web/src/lib/utils.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/pages/DashboardPage.tsx create mode 100644 web/src/pages/DevicesPage.tsx create mode 100644 web/src/pages/LoginPage.tsx create mode 100644 web/src/pages/LogsPage.tsx create mode 100644 web/src/pages/ProxyPage.tsx create mode 100644 web/src/pages/SettingsPage.tsx create mode 100644 web/src/pages/SmsPage.tsx create mode 100644 web/src/store/auth.tsx create mode 100644 web/src/types.ts create mode 100644 web/src/vocat.css create mode 100644 web/tailwind.config.js create mode 100644 web/tsconfig.app.json create mode 100644 web/tsconfig.json create mode 100644 web/tsconfig.node.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5514800 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# ---- Binaries / build outputs ---- +/vocat +/vocat.exe +/build/vocat +/build/vocat-linux-amd64 +/build/vocat-linux-amd64.exe +*.exe +*.dll +*.so +*.dylib + +# ---- Cookie / secret files (NEVER commit) ---- +vc.jar +*.cookies +*.session + +# ---- Frontend build products ---- +web/dist/ +web/build/ +web/node_modules/ + +# ---- Go build ---- +*.test +*.out + +# ---- Python (build/ helpers) ---- +build/__pycache__/ +__pycache__/ +*.pyc + +# ---- Docs / scratch ---- +*.md +*.txt +build/lists/ + +# ---- Editor / IDE ---- +.vscode/ +.idea/ + +# ---- OS junk ---- +.DS_Store +Thumbs.db + +# ---- Claude Code / agent ---- +.claude/ diff --git a/build/deploy.py b/build/deploy.py new file mode 100644 index 0000000..55ac51e --- /dev/null +++ b/build/deploy.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Deploy the freshly built vocat binary to the test machine and restart the service.""" +import time + +import paramiko + +HOST, USER, PASSWORD = "192.168.2.222", "root", "lammy520" +LOCAL_BINARY = r"E:\tool\vocat\build\vocat-linux-amd64" +REMOTE_TMP = "/opt/vocat/bin/vocat.new" +REMOTE_BIN = "/opt/vocat/bin/vocat" + + +def run(client, command, timeout=60): + _, stdout, stderr = client.exec_command(command, timeout=timeout) + out = stdout.read().decode("utf-8", "replace") + err = stderr.read().decode("utf-8", "replace") + code = stdout.channel.recv_exit_status() + if out.strip(): + print(out) + if err.strip(): + print(err) + if code != 0: + raise SystemExit(f"command failed ({code}): {command}") + + +def main(): + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(HOST, username=USER, password=PASSWORD, timeout=10) + + stamp = time.strftime("%Y%m%d-%H%M%S") + # Use SQLite's online backup command so WAL contents are included. + database_backup = f"/opt/vocat/data/vocat.db.bak-deploy-{stamp}" + run(client, f'''sqlite3 /opt/vocat/data/vocat.db ".backup '{database_backup}'"''') + sftp = client.open_sftp() + print(f"uploading {LOCAL_BINARY} -> {REMOTE_TMP} ...") + sftp.put(LOCAL_BINARY, REMOTE_TMP) + sftp.close() + + run(client, f"chmod 0755 {REMOTE_TMP}") + run(client, f"cp -a {REMOTE_BIN} {REMOTE_BIN}.bak-deploy-{stamp}") + run(client, f"mv {REMOTE_TMP} {REMOTE_BIN}") + run(client, "systemctl restart vocat") + time.sleep(2) + run(client, "systemctl is-active vocat && systemctl show vocat -p MainPID --value") + run(client, "curl -fsS http://127.0.0.1:7575/api/health || true") + client.close() + print("deploy OK") + + +if __name__ == "__main__": + main() diff --git a/build/smoke_verify.py b/build/smoke_verify.py new file mode 100644 index 0000000..f58ff19 --- /dev/null +++ b/build/smoke_verify.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Verify the current vocat deployment on 192.168.2.222: +1. notification channels trimmed to telegram/email/webhook/bark/pushplus +2. removed channels (feishu/qq/weixin) rejected +3. the new unsaved-config front-proxy probe endpoint (UDP Associate for VoWiFi) +4. the served frontend bundle reflects the changes (probe button present, API + docs button and Feishu/QQ tabs gone) +""" +import http.client +import http.cookiejar +import json +import re +import time +import urllib.error +import urllib.request + +BASE = "http://192.168.2.222:7575" +USERNAME = "admin" +PASSWORD = "vocat520" + +jar = http.cookiejar.CookieJar() +opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar)) +results = [] + + +def _once(method, path, body, csrf, timeout, raw): + req = urllib.request.Request(BASE + path, method=method) + data = None + if body is not None: + req.add_header("Content-Type", "application/json") + data = json.dumps(body).encode() + if csrf: + req.add_header("X-CSRF-Token", csrf) + req.add_header("Connection", "close") + try: + with opener.open(req, data=data, timeout=timeout) as resp: + payload = resp.read().decode("utf-8", "replace") + return (resp.status, payload) if raw else (resp.status, json.loads(payload) if payload else {}) + except urllib.error.HTTPError as exc: + text = exc.read().decode("utf-8", "replace") + if raw: + return exc.code, text + try: + return exc.code, json.loads(text) + except json.JSONDecodeError: + return exc.code, {"raw": text[:200]} + + +def call(method, path, body=None, csrf=None, timeout=40, raw=False): + # The Windows TCP stack sometimes reports a RST right after the server has + # already sent a complete response; retry those client-side resets. + last = None + for attempt in range(4): + try: + return _once(method, path, body, csrf, timeout, raw) + except (ConnectionResetError, urllib.error.URLError, http.client.RemoteDisconnected) as exc: + last = exc + time.sleep(0.4 * (attempt + 1)) + raise last + + +def check(name, cond, detail=""): + results.append((name, bool(cond))) + print(("PASS " if cond else "FAIL ") + name + (f" | {detail}" if detail and not cond else "")) + + +def bundle_has(bundle, s): + # bundle may keep Chinese literally or \uXXXX-escaped depending on charset + if s in bundle: + return True + esc = "".join("\\u%04x" % ord(c) for c in s) + return esc.lower() in bundle.lower() + + +# 1. login +st, login = call("POST", "/api/auth/login", {"username": USERNAME, "password": PASSWORD}) +csrf = (login.get("data") or {}).get("csrf_token", "") +check("登录 admin", st == 200 and bool(csrf), json.dumps(login, ensure_ascii=False)[:160]) + +# 2. notifications trimmed to exactly the 5 kept channels +st, notif = call("GET", "/api/settings/notifications") +data = notif.get("data") or {} +channels = set(data.keys()) +expect = {"telegram", "email", "webhook", "bark", "pushplus"} +check("通知渠道恰好 5 个", st == 200 and channels == expect, ",".join(sorted(channels))) +check("feishu/qq/weixin 已移除", not ({"feishu", "qq", "weixin"} & channels), ",".join(sorted(channels))) + +# 3. removed channels rejected by both PUT and the test endpoint +st, resp = call("PUT", "/api/settings/notifications", {"feishu": {"enabled": False}}, csrf) +check("PUT feishu 配置被拒(400)", st == 400, f"status={st} {json.dumps(resp)[:120]}") +for removed in ("feishu", "qq", "weixin"): + st, _ = call("POST", f"/api/settings/notifications/{removed}/test", {}, csrf) + check(f"{removed} 测试端点 404", st == 404, f"status={st}") + +# 4. new unsaved-config front-proxy probe endpoint (UDP Associate for VoWiFi) +st, probe = call("POST", "/api/upstream-proxy-probe", {"addr": "127.0.0.1:9"}, csrf, timeout=20) +pdata = probe.get("data") or {} +presult = pdata.get("probe") or {} +check("探测端点存在且返回 probe 结构", st == 200 and "probe" in pdata, f"status={st} {json.dumps(probe, ensure_ascii=False)[:200]}") +check( + "不可达地址正确判定 reachable/udp=false", + presult.get("reachable") is False and presult.get("udp_associate_ok") is False, + json.dumps(presult, ensure_ascii=False)[:200], +) +st, _ = call("POST", "/api/upstream-proxy-probe", {"addr": ""}, csrf, timeout=20) +check("空地址探测返回 400", st == 400, f"status={st}") + +# 5. frontend bundle reflects the changes (the app code lives in /assets/index-*.js) +st, index = call("GET", "/", raw=True) +m = re.search(r'src="(/assets/index-[^"]+\.js)"', index) +bundle = "" +if m: + _, bundle = call("GET", m.group(1), raw=True) +check("获取前端 JS bundle", bool(bundle), "no bundle url in index.html") +if bundle: + check("bundle 含「检测连通性」按钮", bundle_has(bundle, "检测连通性")) + check("bundle 含 UDP Associate(VoWiFi) 探测", bundle_has(bundle, "UDP Associate")) + check("bundle 已无「API 文档」按钮", not bundle_has(bundle, "API 文档")) + check("bundle 已无飞书", not bundle_has(bundle, "飞书")) + check("bundle 已无 QQ 渠道", not bundle_has(bundle, "QQ 机器人") and not bundle_has(bundle, "QQ Bot")) + +failed = [n for n, ok in results if not ok] +print(f"\n==== {len(results) - len(failed)}/{len(results)} 通过 ====") +if failed: + print("失败项:", "; ".join(failed)) + raise SystemExit(1) diff --git a/build/ssh_exec.py b/build/ssh_exec.py new file mode 100644 index 0000000..c371edb --- /dev/null +++ b/build/ssh_exec.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +"""Run a command on the vocat test machine over SSH (paramiko, password auth).""" +import base64 +import sys + +import paramiko + +HOST, USER, PASSWORD = "192.168.2.222", "root", "lammy520" + + +def main() -> int: + if len(sys.argv) > 2 and sys.argv[1] == "--base64": + command = base64.b64decode(sys.argv[2]).decode("utf-8") + else: + command = sys.argv[1] if len(sys.argv) > 1 else "uname -a" + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(HOST, username=USER, password=PASSWORD, timeout=10) + _, stdout, stderr = client.exec_command(command, timeout=60) + out = stdout.read().decode("utf-8", "replace") + err = stderr.read().decode("utf-8", "replace") + code = stdout.channel.recv_exit_status() + client.close() + sys.stdout.write(out) + if err: + sys.stderr.write(err) + return code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go new file mode 100644 index 0000000..819e476 --- /dev/null +++ b/cmd/vocat/main.go @@ -0,0 +1,581 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "vocat/internal/auth" + "vocat/internal/config" + "vocat/internal/device" + "vocat/internal/loghub" + "vocat/internal/server" + "vocat/internal/store" + "vocat/internal/vowifi" + "vocat/internal/vowifi/ike" + "vocat/internal/vowifi/ims" + "vocat/internal/vowifi/integration" + vowifiruntime "vocat/internal/vowifi/runtime" + "vocat/web" +) + +func main() { + logs := loghub.New(slog.NewJSONHandler(os.Stdout, nil), 2000) + logger := slog.New(logs) + if err := run(logger, logs); err != nil { + logger.Error("server stopped", "error", err) + os.Exit(1) + } +} + +func run(logger *slog.Logger, logs *loghub.Hub) error { + cfg, err := config.Load() + if err != nil { + return fmt.Errorf("load configuration: %w", err) + } + if cfg.UsesDefaultCredentials() { + logger.Warn( + "default admin credentials are active; set VOCAT_ADMIN_PASSWORD before exposing the service", + ) + } + + startupContext, cancelStartup := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelStartup() + + database, err := store.Open(startupContext, cfg.DatabasePath) + if err != nil { + return err + } + defer database.Close() + + authService, err := auth.New(database, auth.Options{ + SessionTTL: cfg.SessionTTL, + }) + if err != nil { + return err + } + if err := authService.EnsureAdmin( + startupContext, + cfg.AdminUsername, + cfg.AdminPassword, + ); err != nil { + return err + } + + deviceManager, err := device.NewManager(device.Options{}) + if err != nil { + return fmt.Errorf("create device manager: %w", err) + } + if err := deviceManager.Start(startupContext); err != nil { + logger.Warn("device discovery is not available at startup", "error", err) + } + if err := provisionDiscoveredDevices(startupContext, database, deviceManager); err != nil { + logger.Warn("automatic first-run device provisioning failed", "error", err) + } + defer func() { + stopContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := deviceManager.Stop(stopContext); err != nil { + logger.Warn("stop device manager", "error", err) + } + }() + pollContext, cancelPolling := context.WithCancel(context.Background()) + defer cancelPolling() + go pollDeviceSnapshots(pollContext, logger, database, deviceManager) + go persistLogsToStore(pollContext, logger, logs, database) + + vowifiManager, err := configureVoWiFiRuntime( + startupContext, + logger, + database, + deviceManager, + ) + if err != nil { + return fmt.Errorf("configure VoWiFi runtime: %w", err) + } + defer func() { + stopContext, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if err := vowifiManager.Close(stopContext); err != nil { + logger.Warn("stop VoWiFi runtime", "error", err) + } + }() + + handler, err := server.New(server.Options{ + Store: database, + Auth: authService, + Devices: deviceManager, + VoWiFi: vowifiManager, + Logs: logs, + Assets: web.Dist, + Logger: logger, + SecureCookies: cfg.SecureCookies, + MaxRequestBodyBytes: cfg.MaxRequestBodyBytes, + }) + if err != nil { + return err + } + go handler.StartLogRetentionLoop(pollContext, time.Minute) + go handler.StartSMSSyncLoop(pollContext, 15*time.Second) + + httpServer := &http.Server{ + Addr: cfg.Address, + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + ReadTimeout: 15 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 90 * time.Second, + MaxHeaderBytes: 1 << 20, + } + + signalContext, stopSignals := signal.NotifyContext( + context.Background(), + os.Interrupt, + syscall.SIGTERM, + ) + defer stopSignals() + + serverError := make(chan error, 1) + go func() { + logger.Info("HTTP server listening", "address", cfg.Address) + err := httpServer.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + serverError <- err + }() + + select { + case err := <-serverError: + return err + case <-signalContext.Done(): + logger.Info("shutdown signal received") + } + + shutdownContext, cancelShutdown := context.WithTimeout( + context.Background(), + cfg.ShutdownTimeout, + ) + defer cancelShutdown() + if err := httpServer.Shutdown(shutdownContext); err != nil { + _ = httpServer.Close() + return fmt.Errorf("graceful HTTP shutdown: %w", err) + } + return <-serverError +} + +func configureVoWiFiRuntime( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + deviceManager *device.Manager, +) (*vowifiruntime.Manager, error) { + mapper := integration.ATMapper{ + Store: database, + Devices: deviceManager, + } + adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{ + // The test deployment is deliberately non-cellular. VoWiFi teardown + // may restore CFUN, but it must never reactivate a PDP context. + RestoreCellularData: false, + }) + if err != nil { + return nil, err + } + projector := integration.StateProjector{ + Store: database, + Devices: mapper, + } + manager := vowifiruntime.New(vowifiruntime.Options{ + Logger: logger, + OnState: projector.Save, + Factory: func(factoryContext context.Context, deviceID string) (*vowifi.Orchestrator, error) { + deviceConfig, err := database.Device(factoryContext, deviceID) + if err != nil { + return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err) + } + return newVoWiFiOrchestrator(deviceConfig, database, adapter) + }, + }) + + configured, err := database.ListDevices(ctx) + if err != nil { + _ = manager.Close(context.Background()) + return nil, err + } + for _, deviceConfig := range configured { + if err := manager.Ensure(ctx, deviceConfig.ID); err != nil { + _ = manager.Close(context.Background()) + return nil, fmt.Errorf("register device %q VoWiFi runtime: %w", deviceConfig.ID, err) + } + if deviceConfig.VoWiFiEnabled { + if _, err := manager.RequestEnabled(deviceConfig.ID, true); err != nil { + _ = manager.Close(context.Background()) + return nil, fmt.Errorf("start device %q VoWiFi policy: %w", deviceConfig.ID, err) + } + } + } + return manager, nil +} + +func newVoWiFiOrchestrator( + deviceConfig store.Device, + database *store.Store, + adapter *vowifi.EC20Adapter, +) (*vowifi.Orchestrator, error) { + apn := deviceConfig.APN + if apn == "" { + apn = "ims" + } + tunnelProvider, err := ike.NewProvider(ike.Config{APN: apn}) + if err != nil { + return nil, fmt.Errorf("device %q IKE provider: %w", deviceConfig.ID, err) + } + imsProvider, err := ims.NewProvider(adapter, ims.Config{ + // O2 Germany uses UDP for the SIP/IMS leg delivered through the SWu + // tunnel. This will be selected per home PLMN once the live probe has + // confirmed the expected registration challenge. + Transport: "udp", + // Some Vodafone UK SIM profiles leave AT+CSCA empty; Vodafone publishes + // this service-centre number for manual SMS setup. + SMSCenter: "+447785016005", + OnSMS: func(ctx context.Context, message ims.ReceivedSMS) error { + extra, _ := json.Marshal(map[string]any{ + "transport": "ims", + "encoding": message.Encoding, + "concat": message.Concat, + "rp_reference": message.RPReference, + "call_id": message.CallID, + "received_at": message.Timestamp, + "service_center_timestamp": message.ServiceCenterTimestamp, + "raw_rpdu": message.RawRPDU, + "raw_tpdu": message.RawTPDU, + }) + partsTotal := 1 + if message.Concat != nil && message.Concat.Total > 0 { + partsTotal = message.Concat.Total + } + _, saveErr := database.SaveSMSMessage(ctx, store.SMSMessage{ + MessageID: message.MessageID, + DeviceID: message.DeviceID, + IMSI: message.IMSI, + Peer: message.From, + Direction: "inbound", + Body: message.Text, + Timestamp: message.Timestamp, + Status: "received", + Source: "ims", + PartsTotal: partsTotal, + Read: false, + Extra: extra, + }) + return saveErr + }, + OnSMSStatus: func(ctx context.Context, report ims.ReceivedSMSStatus) error { + deliveryReport := store.SMSDeliveryReport{ + DeviceID: report.DeviceID, + IMSI: report.IMSI, + Peer: report.To, + Source: "ims", + MessageReference: report.MessageReference, + StatusCode: report.StatusCode, + DeliveryState: report.DeliveryStatus, + ServiceCenterTime: report.ServiceCenterTimestamp, + DischargeTime: report.DischargeTimestamp, + ReceivedAt: report.Timestamp, + } + var applyErr error + for attempt := 0; attempt < 10; attempt++ { + _, applyErr = database.ApplySMSDeliveryReport(ctx, deliveryReport) + if !errors.Is(applyErr, store.ErrNotFound) { + return applyErr + } + // A status report can race the API handler persisting the SIP 202 + // result. Give that write a brief chance to complete. + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + // A late report from before this process started must still be + // acknowledged, otherwise the SMSC will keep retransmitting it. + return nil + }, + }) + if err != nil { + return nil, fmt.Errorf("device %q IMS provider: %w", deviceConfig.ID, err) + } + orchestrator, err := vowifi.New(vowifi.Dependencies{ + SIM: adapter, + AKA: adapter, + Radio: adapter, + Proxy: integration.ProxyResolver{Store: database}, + Tunnel: tunnelProvider, + IMS: imsProvider, + Phones: integration.PhoneStore{Store: database, DeviceID: deviceConfig.ID}, + }, vowifi.Options{ + DeviceID: deviceConfig.ID, + AllowIMSWithoutSMS: true, + }) + if err != nil { + return nil, fmt.Errorf("device %q VoWiFi orchestrator: %w", deviceConfig.ID, err) + } + return orchestrator, nil +} + +func provisionDiscoveredDevices( + ctx context.Context, + database *store.Store, + manager *device.Manager, +) error { + configured, err := database.ListDevices(ctx) + if err != nil { + return err + } + if len(configured) != 0 { + return nil + } + for _, discovered := range manager.List() { + candidate := discovered.Candidate + backend := "at" + control := candidate.ATPort.OpenPath() + if candidate.QMIControl != "" { + backend = "qmi" + control = candidate.QMIControl + } + name := candidate.Product + if name == "" || strings.EqualFold(name, "Android") { + name = "Quectel EC20 / EC25" + } + if err := database.UpsertDevice(ctx, store.Device{ + ID: discovered.ID, + Name: name, + Interface: candidate.NetworkInterface, + ControlDevice: control, + ATPort: candidate.ATPort.OpenPath(), + USBPath: candidate.USBPath, + ProxyPort: 1080, + BaudRate: 115200, + DataBits: 8, + StopBits: 1, + Parity: "none", + DeviceBackend: backend, + ESIMTransport: backend, + NetworkEnabled: false, + SMSEnabled: true, + VoWiFiEnabled: false, + }); err != nil { + return err + } + } + return nil +} + +// persistLogsToStore subscribes to the live log hub and durably appends every +// entry to the log_events table, so runtime logs survive restarts and can be +// pruned by the configured retention policy. +func persistLogsToStore( + ctx context.Context, + logger *slog.Logger, + logs *loghub.Hub, + database *store.Store, +) { + entries, cancel := logs.Subscribe(512) + defer cancel() + for { + select { + case <-ctx.Done(): + return + case entry, ok := <-entries: + if !ok { + return + } + var fields json.RawMessage + if len(entry.Fields) > 0 { + if raw, err := json.Marshal(entry.Fields); err == nil { + fields = raw + } + } + if _, err := database.AppendLogEvent(ctx, store.LogEvent{ + Time: entry.Time, + Level: entry.Level, + Message: entry.Message, + Caller: entry.Caller, + Fields: fields, + }); err != nil && ctx.Err() == nil { + logger.Warn("persist log event failed", "error", err) + } + } + } +} + +func pollDeviceSnapshots( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, +) { + refresh := func() { + discoveryContext, cancelDiscovery := context.WithTimeout(ctx, 10*time.Second) + _, err := manager.Discover(discoveryContext) + cancelDiscovery() + if err != nil { + logger.Debug("periodic modem discovery failed", "error", err) + return + } + for _, entry := range manager.List() { + if !entry.Discovered { + continue + } + refreshContext, cancelRefresh := context.WithTimeout(ctx, 30*time.Second) + snapshot, err := manager.Refresh(refreshContext, entry.ID) + cancelRefresh() + if err != nil && ctx.Err() == nil { + logger.Warn("modem snapshot refresh failed", "device_id", entry.ID, "error", err) + } + if err == nil && ctx.Err() == nil { + enforceCardRegion(ctx, logger, database, manager, entry.ID, &snapshot) + } + } + } + refresh() + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refresh() + } + } +} + +// cardPolicySourceRegionBlock marks a card policy that was written automatically +// because the inserted SIM belongs to a region the product does not serve. It +// doubles as the persistent record that the radio was forced off by us, so the +// block survives restarts and can be lifted when an allowed card is detected. +const cardPolicySourceRegionBlock = "auto_region_block" + +// enforceCardRegion applies the regional service policy for one refreshed +// device. A SIM whose IMSI home MCC is blocked (mainland China, 460/461) is +// denied service: the radio is forced into airplane mode and a blocking card +// policy is persisted. The check is fail-open — it only acts on a positively +// read blocked IMSI — and the lift path only runs once the current card is +// positively confirmed to be allowed, so an unreadable IMSI never causes a +// block or a spurious restore. +func enforceCardRegion( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, + id string, + snapshot *device.Snapshot, +) { + if snapshot == nil || !snapshot.SIMReady { + return + } + imsi := strings.TrimSpace(snapshot.IMSI) + if imsi == "" { + // Region unknown: hold the current state rather than block or restore. + return + } + if reason := device.RegionBlockReason(imsi); reason != "" { + if !snapshot.FlightMode { + flightContext, cancelFlight := context.WithTimeout(ctx, 30*time.Second) + _, err := manager.SetFlight(flightContext, id, true) + cancelFlight() + if err != nil && ctx.Err() == nil { + logger.Warn( + "region block: failed to force airplane mode", + "device_id", id, "error", err, + ) + } + } + if snapshot.ICCID != "" { + policy := store.CardPolicy{ + ICCID: snapshot.ICCID, + NetworkEnabled: false, + VoWiFiEnabled: false, + AirplaneEnabled: true, + IPVersion: "IPV4V6", + Source: cardPolicySourceRegionBlock, + } + if err := database.UpsertCardPolicy(ctx, policy); err != nil && ctx.Err() == nil { + logger.Warn( + "region block: failed to persist card policy", + "device_id", id, "iccid", snapshot.ICCID, "error", err, + ) + } + } + logger.Warn( + "blocked SIM detected; service disabled and radio forced off", + "device_id", id, "iccid", snapshot.ICCID, "imsi", imsi, "reason", reason, + ) + return + } + liftCardRegionBlock(ctx, logger, database, manager, id, snapshot) +} + +// liftCardRegionBlock reverses an automatic region block once the current SIM +// is positively confirmed to be allowed. It restores the radio only when an +// outstanding auto-forced block exists, so it never overrides a flight mode the +// user enabled deliberately. +func liftCardRegionBlock( + ctx context.Context, + logger *slog.Logger, + database *store.Store, + manager *device.Manager, + id string, + snapshot *device.Snapshot, +) { + policies, err := database.ListCardPolicies(ctx) + if err != nil { + if ctx.Err() == nil { + logger.Warn("region block: failed to list card policies", "error", err) + } + return + } + outstanding := make([]store.CardPolicy, 0, 1) + for _, policy := range policies { + if policy.Source == cardPolicySourceRegionBlock { + outstanding = append(outstanding, policy) + } + } + if len(outstanding) == 0 { + return + } + if snapshot.FlightMode { + flightContext, cancelFlight := context.WithTimeout(ctx, 30*time.Second) + _, err := manager.SetFlight(flightContext, id, false) + cancelFlight() + if err != nil && ctx.Err() == nil { + logger.Warn( + "region block: failed to restore radio", + "device_id", id, "error", err, + ) + return + } + } + for _, policy := range outstanding { + if err := database.DeleteCardPolicy(ctx, policy.ICCID); err != nil && ctx.Err() == nil { + logger.Warn( + "region block: failed to clear auto policy", + "iccid", policy.ICCID, "error", err, + ) + } + } + logger.Info( + "region block lifted; SIM is allowed", + "device_id", id, "iccid", snapshot.ICCID, "imsi", snapshot.IMSI, + ) +} diff --git a/cmd/vocat/main_test.go b/cmd/vocat/main_test.go new file mode 100644 index 0000000..8240572 --- /dev/null +++ b/cmd/vocat/main_test.go @@ -0,0 +1,223 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "testing" + "time" + + "vocat/internal/device" + "vocat/internal/modem" + "vocat/internal/store" +) + +// fakeModemClient is a minimal scripted modem.Client for exercising the region +// enforcement orchestration (flight-mode flips) without hardware. +type fakeModemClient struct { + steps []fakeStep + index int +} + +type fakeStep struct { + command string + lines []string +} + +func (client *fakeModemClient) Execute(_ context.Context, command string) (modem.Response, error) { + if client.index >= len(client.steps) { + return modem.Response{}, fmt.Errorf("unexpected command %q", command) + } + step := client.steps[client.index] + client.index++ + if command != step.command { + return modem.Response{}, fmt.Errorf("command %q, want %q", command, step.command) + } + return modem.Response{Command: command, Lines: step.lines, Final: "OK"}, nil +} + +func (client *fakeModemClient) WaitURC(context.Context, func(string) bool) (string, error) { + return "", errors.New("no URC scripted") +} + +func (client *fakeModemClient) Close() error { return nil } + +func (client *fakeModemClient) assertExhausted(t *testing.T) { + t.Helper() + if client.index != len(client.steps) { + t.Fatalf("consumed %d of %d scripted commands", client.index, len(client.steps)) + } +} + +type fakeDiscoverer struct{ candidates []modem.Candidate } + +func (discoverer fakeDiscoverer) Discover(context.Context) ([]modem.Candidate, error) { + return discoverer.candidates, nil +} + +type fakeOpener struct{ client modem.Client } + +func (opener fakeOpener) Open(context.Context, modem.Port) (modem.Client, error) { + return opener.client, nil +} + +const regionTestDeviceID = "quectel-region-test" + +func newRegionTestManager(t *testing.T, client modem.Client) *device.Manager { + t.Helper() + manager, err := device.NewManager(device.Options{ + Discoverer: fakeDiscoverer{candidates: []modem.Candidate{{ + ID: regionTestDeviceID, + Product: "EC20", + ATPort: modem.Port{Path: "/dev/ttyUSB2", Role: modem.PortRoleAT}, + }}}, + Opener: fakeOpener{client: client}, + CommandTimeout: time.Second, + LongTimeout: time.Second, + }) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if err := manager.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { _ = manager.Stop(context.Background()) }) + return manager +} + +func newRegionTestStore(t *testing.T) *store.Store { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func regionTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func TestEnforceCardRegionForcesAirplaneAndPersistsPolicy(t *testing.T) { + client := &fakeModemClient{steps: []fakeStep{ + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + {command: "AT+CFUN=4"}, + {command: "AT+CFUN?", lines: []string{"+CFUN: 4"}}, + }} + manager := newRegionTestManager(t, client) + database := newRegionTestStore(t) + + snapshot := &device.Snapshot{ + DeviceID: regionTestDeviceID, + SIMReady: true, + IMSI: "460001234567890", + ICCID: "89860012345678901234", + } + enforceCardRegion(context.Background(), regionTestLogger(), database, manager, regionTestDeviceID, snapshot) + client.assertExhausted(t) + + policy, err := database.CardPolicy(context.Background(), snapshot.ICCID) + if err != nil { + t.Fatalf("CardPolicy: %v", err) + } + if policy.Source != cardPolicySourceRegionBlock { + t.Fatalf("policy source = %q, want %q", policy.Source, cardPolicySourceRegionBlock) + } + if policy.NetworkEnabled || policy.VoWiFiEnabled || !policy.AirplaneEnabled { + t.Fatalf("policy switches = %#v, want all service off and airplane on", policy) + } +} + +func TestEnforceCardRegionSkipsRadioWhenAlreadyOff(t *testing.T) { + client := &fakeModemClient{} + manager := newRegionTestManager(t, client) + database := newRegionTestStore(t) + + snapshot := &device.Snapshot{ + DeviceID: regionTestDeviceID, + SIMReady: true, + IMSI: "461001234567890", + ICCID: "89860012345678901234", + FlightMode: true, + } + enforceCardRegion(context.Background(), regionTestLogger(), database, manager, regionTestDeviceID, snapshot) + client.assertExhausted(t) + + if _, err := database.CardPolicy(context.Background(), snapshot.ICCID); err != nil { + t.Fatalf("expected a persisted block policy even with the radio already off: %v", err) + } +} + +func TestEnforceCardRegionLiftsBlockForAllowedSIM(t *testing.T) { + client := &fakeModemClient{steps: []fakeStep{ + {command: "AT+CFUN?", lines: []string{"+CFUN: 4"}}, + {command: "AT+CFUN=1"}, + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + }} + manager := newRegionTestManager(t, client) + database := newRegionTestStore(t) + + if err := database.UpsertCardPolicy(context.Background(), store.CardPolicy{ + ICCID: "89860012345678901234", + AirplaneEnabled: true, + IPVersion: "IPV4V6", + Source: cardPolicySourceRegionBlock, + }); err != nil { + t.Fatalf("seed block policy: %v", err) + } + + snapshot := &device.Snapshot{ + DeviceID: regionTestDeviceID, + SIMReady: true, + IMSI: "310260123456789", + ICCID: "89012601234567890123", + FlightMode: true, + } + enforceCardRegion(context.Background(), regionTestLogger(), database, manager, regionTestDeviceID, snapshot) + client.assertExhausted(t) + + if _, err := database.CardPolicy(context.Background(), "89860012345678901234"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("expected the auto block policy to be cleared, got err=%v", err) + } +} + +func TestEnforceCardRegionLeavesAllowedSIMWithoutPriorBlockAlone(t *testing.T) { + client := &fakeModemClient{} + manager := newRegionTestManager(t, client) + database := newRegionTestStore(t) + + snapshot := &device.Snapshot{ + DeviceID: regionTestDeviceID, + SIMReady: true, + IMSI: "310260123456789", + ICCID: "89012601234567890123", + } + enforceCardRegion(context.Background(), regionTestLogger(), database, manager, regionTestDeviceID, snapshot) + client.assertExhausted(t) +} + +func TestEnforceCardRegionIgnoresUnknownOrNotReadySIM(t *testing.T) { + client := &fakeModemClient{} + manager := newRegionTestManager(t, client) + database := newRegionTestStore(t) + + // Not ready: no action at all. + notReady := &device.Snapshot{DeviceID: regionTestDeviceID, SIMReady: false, IMSI: "460001234567890"} + enforceCardRegion(context.Background(), regionTestLogger(), database, manager, regionTestDeviceID, notReady) + + // Ready but IMSI unknown: hold state, neither block nor lift. + unknown := &device.Snapshot{DeviceID: regionTestDeviceID, SIMReady: true, IMSI: ""} + enforceCardRegion(context.Background(), regionTestLogger(), database, manager, regionTestDeviceID, unknown) + + client.assertExhausted(t) + policies, err := database.ListCardPolicies(context.Background()) + if err != nil { + t.Fatalf("ListCardPolicies: %v", err) + } + if len(policies) != 0 { + t.Fatalf("expected no card policies, got %d", len(policies)) + } +} diff --git a/deploy/vocat.service b/deploy/vocat.service new file mode 100644 index 0000000..3914072 --- /dev/null +++ b/deploy/vocat.service @@ -0,0 +1,40 @@ +[Unit] +Description=vocat cellular and VoWiFi control service +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/opt/vocat +ExecStart=/opt/vocat/bin/vocat +Restart=on-failure +RestartSec=3s +TimeoutStartSec=30s +TimeoutStopSec=20s +Environment=VOCAT_ADDR=0.0.0.0:7575 +Environment=VOCAT_DATABASE_PATH=/opt/vocat/data/vocat.db +EnvironmentFile=/etc/vocat/vocat.env + +AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW +CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW +NoNewPrivileges=true +PrivateTmp=true +PrivateDevices=false +ProtectSystem=strict +ProtectHome=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +ProtectControlGroups=true +ReadWritePaths=/opt/vocat/data +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK +RestrictRealtime=true +LockPersonality=true +MemoryDenyWriteExecute=true +UMask=0077 +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2298c5b --- /dev/null +++ b/go.mod @@ -0,0 +1,23 @@ +module vocat + +go 1.23.0 + +require ( + go.bug.st/serial v1.6.4 + golang.org/x/crypto v0.41.0 + modernc.org/sqlite v1.38.2 +) + +require ( + github.com/creack/goselect v0.1.2 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/sys v0.35.0 // indirect + modernc.org/libc v1.66.3 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c6731d --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= +github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= +go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= +modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= +modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= +modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= +modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/auth/service.go b/internal/auth/service.go new file mode 100644 index 0000000..a917691 --- /dev/null +++ b/internal/auth/service.go @@ -0,0 +1,291 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + "time" + + "golang.org/x/crypto/bcrypt" + + "vocat/internal/store" +) + +var ( + ErrInvalidCredentials = errors.New("invalid credentials") + ErrUnauthorized = errors.New("unauthorized") + ErrInvalidCSRF = errors.New("invalid csrf token") +) + +type Options struct { + SessionTTL time.Duration + BcryptCost int +} + +type Service struct { + store *store.Store + sessionTTL time.Duration + bcryptCost int + dummyHash []byte +} + +type Principal struct { + ID int64 `json:"-"` + Username string `json:"username"` +} + +type Credentials struct { + SessionToken string + CSRFToken string + ExpiresAt time.Time + Principal Principal +} + +type AuthenticatedSession struct { + Principal Principal + ExpiresAt time.Time + tokenHash []byte + csrfHash []byte +} + +func New(database *store.Store, options Options) (*Service, error) { + if database == nil { + return nil, errors.New("auth: store is required") + } + if options.SessionTTL <= 0 { + return nil, errors.New("auth: session TTL must be positive") + } + if options.BcryptCost == 0 { + options.BcryptCost = 12 + } + if options.BcryptCost < bcrypt.MinCost || options.BcryptCost > bcrypt.MaxCost { + return nil, errors.New("auth: bcrypt cost is out of range") + } + dummyHash, err := bcrypt.GenerateFromPassword([]byte("not-a-real-password"), options.BcryptCost) + if err != nil { + return nil, fmt.Errorf("auth: generate timing hash: %w", err) + } + return &Service{ + store: database, + sessionTTL: options.SessionTTL, + bcryptCost: options.BcryptCost, + dummyHash: dummyHash, + }, nil +} + +// EnsureAdmin configures the single administrator. Existing sessions are +// revoked only when the configured username or password changes. +func (s *Service) EnsureAdmin(ctx context.Context, username string, password string) error { + username = strings.TrimSpace(username) + current, err := s.store.CurrentAdmin(ctx) + if err == nil && + current.Username == username && + bcrypt.CompareHashAndPassword(current.PasswordHash, []byte(password)) == nil { + return nil + } + if err != nil && !errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("auth: read configured admin: %w", err) + } + + passwordHash, err := bcrypt.GenerateFromPassword([]byte(password), s.bcryptCost) + if err != nil { + return fmt.Errorf("auth: hash admin password: %w", err) + } + if err := s.store.SetAdmin(ctx, username, passwordHash); err != nil { + return err + } + return nil +} + +func (s *Service) Login(ctx context.Context, username string, password string) (Credentials, error) { + admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) + if errors.Is(err, store.ErrNotFound) { + _ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(password)) + return Credentials{}, ErrInvalidCredentials + } + if err != nil { + return Credentials{}, fmt.Errorf("auth: find admin: %w", err) + } + if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(password)) != nil { + return Credentials{}, ErrInvalidCredentials + } + + if err := s.store.DeleteExpiredSessions(ctx, time.Now()); err != nil { + return Credentials{}, err + } + sessionToken, err := randomToken() + if err != nil { + return Credentials{}, err + } + csrfToken, err := randomToken() + if err != nil { + return Credentials{}, err + } + expiresAt := time.Now().UTC().Add(s.sessionTTL) + if err := s.store.CreateSession( + ctx, + admin.ID, + hashToken(sessionToken), + hashToken(csrfToken), + expiresAt, + ); err != nil { + return Credentials{}, err + } + return Credentials{ + SessionToken: sessionToken, + CSRFToken: csrfToken, + ExpiresAt: expiresAt, + Principal: Principal{ + ID: admin.ID, + Username: admin.Username, + }, + }, nil +} + +func (s *Service) Authenticate(ctx context.Context, sessionToken string) (AuthenticatedSession, error) { + if sessionToken == "" { + return AuthenticatedSession{}, ErrUnauthorized + } + tokenHash := hashToken(sessionToken) + session, err := s.store.SessionByTokenHash(ctx, tokenHash) + if errors.Is(err, store.ErrNotFound) { + return AuthenticatedSession{}, ErrUnauthorized + } + if err != nil { + return AuthenticatedSession{}, fmt.Errorf("auth: load session: %w", err) + } + if !session.ExpiresAt.After(time.Now().UTC()) { + _ = s.store.DeleteSession(ctx, tokenHash) + return AuthenticatedSession{}, ErrUnauthorized + } + return AuthenticatedSession{ + Principal: Principal{ + ID: session.Admin.ID, + Username: session.Admin.Username, + }, + ExpiresAt: session.ExpiresAt, + tokenHash: tokenHash, + csrfHash: session.CSRFHash, + }, nil +} + +// RotateCSRF replaces the session-bound CSRF value and returns the new raw +// token. Only its SHA-256 digest is persisted. +func (s *Service) RotateCSRF(ctx context.Context, sessionToken string) (AuthenticatedSession, string, error) { + return s.CSRFToken(ctx, sessionToken, "") +} + +// CSRFToken reuses a valid CSRF cookie or rotates it when the cookie is absent +// or stale. Reuse prevents one browser tab from invalidating another tab's +// session-bound token. +func (s *Service) CSRFToken( + ctx context.Context, + sessionToken string, + existingToken string, +) (AuthenticatedSession, string, error) { + session, err := s.Authenticate(ctx, sessionToken) + if err != nil { + return AuthenticatedSession{}, "", err + } + if existingToken != "" { + existingHash := hashToken(existingToken) + if subtle.ConstantTimeCompare(existingHash, session.csrfHash) == 1 { + return session, existingToken, nil + } + } + csrfToken, err := randomToken() + if err != nil { + return AuthenticatedSession{}, "", err + } + csrfHash := hashToken(csrfToken) + if err := s.store.UpdateSessionCSRF(ctx, session.tokenHash, csrfHash); err != nil { + if errors.Is(err, store.ErrNotFound) { + return AuthenticatedSession{}, "", ErrUnauthorized + } + return AuthenticatedSession{}, "", err + } + session.csrfHash = csrfHash + return session, csrfToken, nil +} + +func (s *Service) ValidateCSRF( + ctx context.Context, + sessionToken string, + csrfToken string, +) (AuthenticatedSession, error) { + if csrfToken == "" { + return AuthenticatedSession{}, ErrInvalidCSRF + } + session, err := s.Authenticate(ctx, sessionToken) + if err != nil { + return AuthenticatedSession{}, err + } + providedHash := hashToken(csrfToken) + if subtle.ConstantTimeCompare(providedHash, session.csrfHash) != 1 { + return AuthenticatedSession{}, ErrInvalidCSRF + } + return session, nil +} + +func (s *Service) Logout(ctx context.Context, sessionToken string) error { + if sessionToken == "" { + return nil + } + if err := s.store.DeleteSession(ctx, hashToken(sessionToken)); err != nil { + return err + } + return nil +} + +// ChangePassword verifies the current password, replaces it with a fresh +// bcrypt hash and revokes every session through Store.SetAdmin. +func (s *Service) ChangePassword( + ctx context.Context, + username string, + currentPassword string, + newPassword string, +) error { + if len(newPassword) < 12 || len(newPassword) > 1024 { + return errors.New("new password must contain between 12 and 1024 characters") + } + admin, err := s.store.AdminByUsername(ctx, strings.TrimSpace(username)) + if errors.Is(err, store.ErrNotFound) { + _ = bcrypt.CompareHashAndPassword(s.dummyHash, []byte(currentPassword)) + return ErrInvalidCredentials + } + if err != nil { + return fmt.Errorf("auth: find admin: %w", err) + } + if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(currentPassword)) != nil { + return ErrInvalidCredentials + } + if bcrypt.CompareHashAndPassword(admin.PasswordHash, []byte(newPassword)) == nil { + return errors.New("new password must differ from the current password") + } + passwordHash, err := bcrypt.GenerateFromPassword([]byte(newPassword), s.bcryptCost) + if err != nil { + return fmt.Errorf("auth: hash new password: %w", err) + } + if err := s.store.SetAdmin(ctx, admin.Username, passwordHash); err != nil { + return fmt.Errorf("auth: save new password: %w", err) + } + return nil +} + +func randomToken() (string, error) { + buffer := make([]byte, 32) + if _, err := rand.Read(buffer); err != nil { + return "", fmt.Errorf("auth: generate random token: %w", err) + } + return base64.RawURLEncoding.EncodeToString(buffer), nil +} + +func hashToken(token string) []byte { + digest := sha256.Sum256([]byte(token)) + return digest[:] +} diff --git a/internal/auth/service_test.go b/internal/auth/service_test.go new file mode 100644 index 0000000..411d897 --- /dev/null +++ b/internal/auth/service_test.go @@ -0,0 +1,98 @@ +package auth + +import ( + "context" + "errors" + "testing" + "time" + + "golang.org/x/crypto/bcrypt" + + "vocat/internal/store" +) + +func newTestService(t *testing.T) *Service { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + t.Cleanup(func() { + _ = database.Close() + }) + service, err := New(database, Options{ + SessionTTL: time.Hour, + BcryptCost: bcrypt.MinCost, + }) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if err := service.EnsureAdmin(context.Background(), "admin", "correct-password"); err != nil { + t.Fatalf("EnsureAdmin() error = %v", err) + } + return service +} + +func TestLoginAuthenticateCSRFAndLogout(t *testing.T) { + ctx := context.Background() + service := newTestService(t) + + if _, err := service.Login(ctx, "admin", "wrong-password"); !errors.Is(err, ErrInvalidCredentials) { + t.Fatalf("Login() error = %v, want ErrInvalidCredentials", err) + } + credentials, err := service.Login(ctx, "admin", "correct-password") + if err != nil { + t.Fatalf("Login() error = %v", err) + } + + session, err := service.Authenticate(ctx, credentials.SessionToken) + if err != nil { + t.Fatalf("Authenticate() error = %v", err) + } + if session.Principal.Username != "admin" { + t.Fatalf("Principal = %+v", session.Principal) + } + if _, err := service.ValidateCSRF(ctx, credentials.SessionToken, "wrong"); !errors.Is(err, ErrInvalidCSRF) { + t.Fatalf("ValidateCSRF() error = %v, want ErrInvalidCSRF", err) + } + if _, err := service.ValidateCSRF(ctx, credentials.SessionToken, credentials.CSRFToken); err != nil { + t.Fatalf("ValidateCSRF() error = %v", err) + } + _, csrfToken, err := service.CSRFToken( + ctx, + credentials.SessionToken, + credentials.CSRFToken, + ) + if err != nil { + t.Fatalf("CSRFToken() error = %v", err) + } + if csrfToken != credentials.CSRFToken { + t.Fatal("CSRFToken() rotated an already valid token") + } + + if err := service.Logout(ctx, credentials.SessionToken); err != nil { + t.Fatalf("Logout() error = %v", err) + } + if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("Authenticate() after logout error = %v, want ErrUnauthorized", err) + } +} + +func TestEnsureAdminRevokesSessionOnPasswordChange(t *testing.T) { + ctx := context.Background() + service := newTestService(t) + credentials, err := service.Login(ctx, "admin", "correct-password") + if err != nil { + t.Fatal(err) + } + + if err := service.EnsureAdmin(ctx, "admin", "new-password"); err != nil { + t.Fatal(err) + } + if _, err := service.Authenticate(ctx, credentials.SessionToken); !errors.Is(err, ErrUnauthorized) { + t.Fatalf("old session error = %v, want ErrUnauthorized", err) + } + if _, err := service.Login(ctx, "admin", "new-password"); err != nil { + t.Fatalf("login with new password: %v", err) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..a17a422 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,232 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "strconv" + "strings" + "time" +) + +const maxConfigBytes = 1 << 20 + +// Config contains the process-level settings shared by the HTTP and storage +// layers. Environment variables override values loaded from VOCAT_CONFIG. +type Config struct { + Address string + DatabasePath string + AdminUsername string + AdminPassword string + SessionTTL time.Duration + SecureCookies bool + ShutdownTimeout time.Duration + MaxRequestBodyBytes int64 +} + +type fileConfig struct { + Address *string `json:"address"` + DatabasePath *string `json:"database_path"` + AdminUsername *string `json:"admin_username"` + AdminPassword *string `json:"admin_password"` + SessionTTL *string `json:"session_ttl"` + SecureCookies *bool `json:"secure_cookies"` + ShutdownTimeout *string `json:"shutdown_timeout"` + MaxRequestBodyBytes *int64 `json:"max_request_body_bytes"` +} + +// Default returns a configuration suitable for a first local deployment. +// Operators should replace the bootstrap password through +// VOCAT_ADMIN_PASSWORD before exposing the service. +func Default() Config { + return Config{ + Address: "0.0.0.0:7575", + DatabasePath: "./data/vocat.db", + AdminUsername: "admin", + AdminPassword: "admin", + SessionTTL: 24 * time.Hour, + SecureCookies: false, + ShutdownTimeout: 10 * time.Second, + MaxRequestBodyBytes: 1 << 20, + } +} + +// Load reads an optional strict JSON file selected by VOCAT_CONFIG and then +// applies VOCAT_* environment overrides. +func Load() (Config, error) { + cfg := Default() + + if path := strings.TrimSpace(os.Getenv("VOCAT_CONFIG")); path != "" { + fileValues, err := loadFile(path) + if err != nil { + return Config{}, err + } + if err := applyFile(&cfg, fileValues); err != nil { + return Config{}, fmt.Errorf("load config %q: %w", path, err) + } + } + + if err := applyEnvironment(&cfg); err != nil { + return Config{}, err + } + if err := cfg.Validate(); err != nil { + return Config{}, err + } + return cfg, nil +} + +func loadFile(path string) (fileConfig, error) { + file, err := os.Open(path) + if err != nil { + return fileConfig{}, fmt.Errorf("open config %q: %w", path, err) + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return fileConfig{}, fmt.Errorf("stat config %q: %w", path, err) + } + if info.Size() > maxConfigBytes { + return fileConfig{}, fmt.Errorf("config %q exceeds %d bytes", path, maxConfigBytes) + } + + decoder := json.NewDecoder(io.LimitReader(file, maxConfigBytes)) + decoder.DisallowUnknownFields() + + var values fileConfig + if err := decoder.Decode(&values); err != nil { + return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err) + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + err = errors.New("multiple JSON values") + } + return fileConfig{}, fmt.Errorf("decode config %q: %w", path, err) + } + return values, nil +} + +func applyFile(cfg *Config, values fileConfig) error { + if values.Address != nil { + cfg.Address = *values.Address + } + if values.DatabasePath != nil { + cfg.DatabasePath = *values.DatabasePath + } + if values.AdminUsername != nil { + cfg.AdminUsername = *values.AdminUsername + } + if values.AdminPassword != nil { + cfg.AdminPassword = *values.AdminPassword + } + if values.SessionTTL != nil { + duration, err := time.ParseDuration(*values.SessionTTL) + if err != nil { + return fmt.Errorf("session_ttl: %w", err) + } + cfg.SessionTTL = duration + } + if values.SecureCookies != nil { + cfg.SecureCookies = *values.SecureCookies + } + if values.ShutdownTimeout != nil { + duration, err := time.ParseDuration(*values.ShutdownTimeout) + if err != nil { + return fmt.Errorf("shutdown_timeout: %w", err) + } + cfg.ShutdownTimeout = duration + } + if values.MaxRequestBodyBytes != nil { + cfg.MaxRequestBodyBytes = *values.MaxRequestBodyBytes + } + return nil +} + +func applyEnvironment(cfg *Config) error { + applyString := func(name string, target *string) { + if value, ok := os.LookupEnv(name); ok { + *target = value + } + } + + applyString("VOCAT_ADDR", &cfg.Address) + applyString("VOCAT_DATABASE_PATH", &cfg.DatabasePath) + applyString("VOCAT_ADMIN_USERNAME", &cfg.AdminUsername) + applyString("VOCAT_ADMIN_PASSWORD", &cfg.AdminPassword) + + if value, ok := os.LookupEnv("VOCAT_SESSION_TTL"); ok { + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("VOCAT_SESSION_TTL: %w", err) + } + cfg.SessionTTL = duration + } + if value, ok := os.LookupEnv("VOCAT_SECURE_COOKIES"); ok { + secure, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("VOCAT_SECURE_COOKIES: %w", err) + } + cfg.SecureCookies = secure + } + if value, ok := os.LookupEnv("VOCAT_SHUTDOWN_TIMEOUT"); ok { + duration, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("VOCAT_SHUTDOWN_TIMEOUT: %w", err) + } + cfg.ShutdownTimeout = duration + } + if value, ok := os.LookupEnv("VOCAT_MAX_REQUEST_BODY_BYTES"); ok { + size, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return fmt.Errorf("VOCAT_MAX_REQUEST_BODY_BYTES: %w", err) + } + cfg.MaxRequestBodyBytes = size + } + return nil +} + +// Validate rejects settings that would make the server unusable or weaken its +// basic request limits. +func (cfg Config) Validate() error { + host, portText, err := net.SplitHostPort(strings.TrimSpace(cfg.Address)) + if err != nil { + return fmt.Errorf("address: %w", err) + } + _ = host + port, err := strconv.Atoi(portText) + if err != nil || port < 1 || port > 65535 { + return fmt.Errorf("address: invalid TCP port %q", portText) + } + if strings.TrimSpace(cfg.DatabasePath) == "" { + return errors.New("database_path must not be empty") + } + username := strings.TrimSpace(cfg.AdminUsername) + if username == "" || len(username) > 64 { + return errors.New("admin_username must contain between 1 and 64 characters") + } + if strings.ContainsAny(username, "\r\n\t") { + return errors.New("admin_username must not contain control whitespace") + } + if cfg.AdminPassword == "" { + return errors.New("admin_password must not be empty") + } + if cfg.SessionTTL < 5*time.Minute || cfg.SessionTTL > 30*24*time.Hour { + return errors.New("session_ttl must be between 5m and 720h") + } + if cfg.ShutdownTimeout <= 0 || cfg.ShutdownTimeout > 5*time.Minute { + return errors.New("shutdown_timeout must be between 1ns and 5m") + } + if cfg.MaxRequestBodyBytes < 1024 || cfg.MaxRequestBodyBytes > 10<<20 { + return errors.New("max_request_body_bytes must be between 1024 and 10485760") + } + return nil +} + +// UsesDefaultCredentials reports whether the documented bootstrap credentials +// are still active. +func (cfg Config) UsesDefaultCredentials() bool { + return cfg.AdminUsername == "admin" && cfg.AdminPassword == "admin" +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..0c4cae8 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +var configEnvironment = []string{ + "VOCAT_CONFIG", + "VOCAT_ADDR", + "VOCAT_DATABASE_PATH", + "VOCAT_ADMIN_USERNAME", + "VOCAT_ADMIN_PASSWORD", + "VOCAT_SESSION_TTL", + "VOCAT_SECURE_COOKIES", + "VOCAT_SHUTDOWN_TIMEOUT", + "VOCAT_MAX_REQUEST_BODY_BYTES", +} + +func clearConfigEnvironment(t *testing.T) { + t.Helper() + for _, name := range configEnvironment { + t.Setenv(name, "") + if err := os.Unsetenv(name); err != nil { + t.Fatalf("unset %s: %v", name, err) + } + } +} + +func TestLoadDefaults(t *testing.T) { + clearConfigEnvironment(t) + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Address != "0.0.0.0:7575" { + t.Fatalf("Address = %q", cfg.Address) + } + if !cfg.UsesDefaultCredentials() { + t.Fatal("expected bootstrap credentials") + } +} + +func TestLoadFileThenEnvironmentOverride(t *testing.T) { + clearConfigEnvironment(t) + path := filepath.Join(t.TempDir(), "vocat.json") + content := []byte(`{ + "address": "127.0.0.1:8000", + "database_path": "/tmp/from-file.db", + "admin_username": "operator", + "admin_password": "from-file", + "session_ttl": "2h", + "secure_cookies": false, + "shutdown_timeout": "12s", + "max_request_body_bytes": 4096 + }`) + if err := os.WriteFile(path, content, 0o600); err != nil { + t.Fatal(err) + } + + t.Setenv("VOCAT_CONFIG", path) + t.Setenv("VOCAT_ADDR", "0.0.0.0:9000") + t.Setenv("VOCAT_SECURE_COOKIES", "true") + + cfg, err := Load() + if err != nil { + t.Fatalf("Load() error = %v", err) + } + if cfg.Address != "0.0.0.0:9000" || !cfg.SecureCookies { + t.Fatalf("environment override not applied: %+v", cfg) + } + if cfg.AdminUsername != "operator" || cfg.SessionTTL != 2*time.Hour { + t.Fatalf("file values not applied: %+v", cfg) + } +} + +func TestLoadRejectsUnknownJSONField(t *testing.T) { + clearConfigEnvironment(t) + path := filepath.Join(t.TempDir(), "vocat.json") + if err := os.WriteFile(path, []byte(`{"unknown": true}`), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("VOCAT_CONFIG", path) + + if _, err := Load(); err == nil { + t.Fatal("Load() unexpectedly accepted unknown field") + } +} + +func TestLoadRejectsInvalidEnvironment(t *testing.T) { + clearConfigEnvironment(t) + t.Setenv("VOCAT_SESSION_TTL", "tomorrow") + + if _, err := Load(); err == nil { + t.Fatal("Load() unexpectedly accepted invalid duration") + } +} diff --git a/internal/device/controls.go b/internal/device/controls.go new file mode 100644 index 0000000..6cb75b1 --- /dev/null +++ b/internal/device/controls.go @@ -0,0 +1,371 @@ +package device + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode/utf16" + + "vocat/internal/modem" +) + +// USSD dialog states derived from the +CUSD result code. +const ( + ussdStatusFinal = "final" // 0: no further user action required + ussdStatusAwaitingInput = "awaiting_input" // 1: network expects more input + ussdStatusTerminated = "terminated" // 2: terminated by the network + ussdStatusFailed = "failed" // 3/4/5: local answer, unsupported, or timeout +) + +// USSD begins a USSD dialog. When the network expects more input the result +// carries an "awaiting_input" status and a session id that can be continued or +// cancelled; otherwise the dialog is complete and self-contained. +func (manager *Manager) USSD( + ctx context.Context, + id string, + code string, +) (USSDResult, error) { + code = strings.TrimSpace(code) + if !validServiceCode(code) { + return USSDResult{}, errors.New("invalid USSD service code") + } + result, err := manager.runUSSD(ctx, id, fmt.Sprintf(`AT+CUSD=1,"%s",15`, code)) + if err != nil { + return result, err + } + result.Code = code + if result.Status == ussdStatusAwaitingInput { + result.SessionID = manager.openUSSDSession(id) + result.Continueable = true + } + return result, nil +} + +// ContinueUSSD sends follow-up input on an open USSD dialog. +func (manager *Manager) ContinueUSSD( + ctx context.Context, + sessionID string, + input string, +) (USSDResult, error) { + deviceID, err := manager.ussdSessionDevice(sessionID) + if err != nil { + return USSDResult{}, err + } + input = strings.TrimSpace(input) + if input == "" || len(input) > 182 || strings.ContainsAny(input, "\"\r\n") { + return USSDResult{}, errors.New("invalid USSD input") + } + result, err := manager.runUSSD(ctx, deviceID, fmt.Sprintf(`AT+CUSD=1,"%s",15`, input)) + if err != nil { + return result, err + } + result.Code = input + if result.Status == ussdStatusAwaitingInput { + result.SessionID = sessionID + result.Continueable = true + } else { + manager.dropUSSDSession(sessionID) + } + return result, nil +} + +// CancelUSSD terminates an open USSD dialog with AT+CUSD=2. A session abort +// returns OK without a +CUSD result, so unlike start/continue it does not wait +// for an unsolicited response. +func (manager *Manager) CancelUSSD(ctx context.Context, sessionID string) error { + deviceID, err := manager.ussdSessionDevice(sessionID) + if err != nil { + return err + } + defer manager.dropUSSDSession(sessionID) + state, err := manager.lookup(deviceID) + if err != nil { + return err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(deviceID, state); err != nil { + return err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(deviceID, state, nil, err) + return err + } + commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout) + _, err = client.Execute(commandCtx, "AT+CUSD=2") + cancel() + manager.setResult(deviceID, state, nil, err) + return err +} + +// runUSSD issues one CUSD command and waits for the +CUSD unsolicited result. +func (manager *Manager) runUSSD( + ctx context.Context, + id string, + command string, +) (USSDResult, error) { + state, err := manager.lookup(id) + if err != nil { + return USSDResult{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return USSDResult{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return USSDResult{}, err + } + + commandCtx, cancelCommand := manager.withTimeout(ctx, manager.commandTimeout) + _, err = client.Execute(commandCtx, command) + cancelCommand() + if err != nil { + manager.setResult(id, state, nil, err) + return USSDResult{}, err + } + waitCtx, cancelWait := manager.withTimeout(ctx, manager.longTimeout) + defer cancelWait() + line, err := client.WaitURC(waitCtx, func(line string) bool { + return strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+CUSD:") + }) + if err != nil { + manager.setResult(id, state, nil, err) + return USSDResult{}, err + } + result, err := parseUSSDResponse(line) + manager.setResult(id, state, nil, err) + return result, err +} + +func (manager *Manager) openUSSDSession(deviceID string) string { + var token [8]byte + _, _ = rand.Read(token[:]) + id := hex.EncodeToString(token[:]) + manager.mu.Lock() + manager.ussdSessions[id] = ussdSession{deviceID: deviceID, createdAt: time.Now().UTC()} + manager.mu.Unlock() + return id +} + +func (manager *Manager) ussdSessionDevice(sessionID string) (string, error) { + manager.mu.RLock() + session, ok := manager.ussdSessions[strings.TrimSpace(sessionID)] + manager.mu.RUnlock() + if !ok { + return "", ErrUSSDSessionNotFound + } + return session.deviceID, nil +} + +func (manager *Manager) dropUSSDSession(sessionID string) { + manager.mu.Lock() + delete(manager.ussdSessions, strings.TrimSpace(sessionID)) + manager.mu.Unlock() +} + +// parseUSSDResponse parses a +CUSD unsolicited result line, capturing the dialog +// state byte, the decoded text, and the data coding scheme. +func parseUSSDResponse(line string) (USSDResult, error) { + if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+CUSD:") { + return USSDResult{}, errors.New("invalid CUSD response") + } + values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1])) + if len(values) < 2 { + return USSDResult{}, errors.New("CUSD response has no text") + } + code, _ := strconv.Atoi(strings.TrimSpace(values[0])) + payload := strings.Trim(values[1], `"`) + var dcs *int + if len(values) >= 3 { + if value, err := strconv.Atoi(values[2]); err == nil { + dcs = intPointer(value) + } + } + text := payload + if (dcs != nil && *dcs == 72) || looksLikeUCS2(payload) { + if decoded := decodeUCS2(payload); decoded != "" { + text = decoded + } + } + return USSDResult{ + Text: text, + Raw: line, + DCS: dcs, + Status: ussdStatusFromCode(code), + }, nil +} + +func ussdStatusFromCode(code int) string { + switch code { + case 0: + return ussdStatusFinal + case 1: + return ussdStatusAwaitingInput + case 2: + return ussdStatusTerminated + default: + return ussdStatusFailed + } +} + +func looksLikeUCS2(value string) bool { + if len(value) < 4 || len(value)%4 != 0 { + return false + } + if _, err := hex.DecodeString(value); err != nil { + return false + } + return strings.HasPrefix(value, "00") || + strings.IndexFunc(value, func(character rune) bool { + return character >= 'A' && character <= 'F' || + character >= 'a' && character <= 'f' + }) >= 0 +} + +func decodeUCS2(value string) string { + if len(value) < 4 || len(value)%4 != 0 { + return "" + } + units := make([]uint16, 0, len(value)/4) + for index := 0; index < len(value); index += 4 { + unit, err := strconv.ParseUint(value[index:index+4], 16, 16) + if err != nil { + return "" + } + units = append(units, uint16(unit)) + } + return string(utf16.Decode(units)) +} + +func validServiceCode(value string) bool { + if value == "" || len(value) > 40 { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + if character != '*' && character != '#' && character != '+' { + return false + } + } + } + return true +} + +func (manager *Manager) SetFlight( + ctx context.Context, + id string, + enabled bool, +) (FlightResult, error) { + state, err := manager.lookup(id) + if err != nil { + return FlightResult{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return FlightResult{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return FlightResult{}, err + } + previous, err := manager.readOperatingMode(ctx, client) + if err != nil { + manager.setResult(id, state, nil, err) + return FlightResult{}, err + } + target := previous + if enabled { + if !isRadioOffMode(previous) { + saved := previous + state.preFlightMode = &saved + target = 4 + } + } else if isRadioOffMode(previous) { + target = 1 + if state.preFlightMode != nil && !isRadioOffMode(*state.preFlightMode) { + target = *state.preFlightMode + } + } + + changed := target != previous + if changed { + if _, err := manager.command( + ctx, + client, + fmt.Sprintf("AT+CFUN=%d", target), + ); err != nil { + manager.setResult(id, state, nil, err) + return FlightResult{ + PreviousMode: previous, + CurrentMode: previous, + FlightMode: isRadioOffMode(previous), + RadioOff: isRadioOffMode(previous), + }, err + } + } + current, err := manager.readOperatingMode(ctx, client) + if err != nil { + manager.setResult(id, state, nil, err) + return FlightResult{ + PreviousMode: previous, + CurrentMode: target, + Changed: changed, + FlightMode: isRadioOffMode(target), + RadioOff: isRadioOffMode(target), + }, err + } + if !enabled && !isRadioOffMode(current) { + state.preFlightMode = nil + } + manager.updateSnapshotMode(id, state, current) + manager.setResult(id, state, nil, nil) + return FlightResult{ + PreviousMode: previous, + CurrentMode: current, + Changed: changed, + FlightMode: isRadioOffMode(current), + RadioOff: isRadioOffMode(current), + }, nil +} + +func (manager *Manager) readOperatingMode( + ctx context.Context, + client modem.Client, +) (int, error) { + response, err := manager.command(ctx, client, "AT+CFUN?") + if err != nil { + return 0, err + } + mode, ok := parseCFUN(response) + if !ok { + return 0, errors.New("modem did not return a valid +CFUN value") + } + return mode, nil +} + +func (manager *Manager) updateSnapshotMode( + id string, + state *managedDevice, + mode int, +) { + manager.mu.Lock() + defer manager.mu.Unlock() + if manager.devices[id] != state || state.snapshot == nil { + return + } + state.snapshot.OperatingMode = mode + state.snapshot.ModeKnown = true + state.snapshot.FlightMode = isRadioOffMode(mode) + state.snapshot.RadioOff = state.snapshot.FlightMode +} diff --git a/internal/device/controls_test.go b/internal/device/controls_test.go new file mode 100644 index 0000000..d10d201 --- /dev/null +++ b/internal/device/controls_test.go @@ -0,0 +1,78 @@ +package device + +import ( + "context" + "testing" +) + +func TestSetFlightPreservesRawCFUNZero(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CFUN?", response: okResponse("+CFUN: 0")}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 0")}, + }} + manager, id := newStartedTestManager(t, client) + + result, err := manager.SetFlight(context.Background(), id, true) + if err != nil { + t.Fatalf("SetFlight: %v", err) + } + if result.Changed || result.PreviousMode != 0 || result.CurrentMode != 0 || + !result.FlightMode || !result.RadioOff { + t.Fatalf("result = %#v", result) + } + client.assertDone(t) +} + +func TestSetFlightRestoresPreviousFunctionalMode(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, + {command: "AT+CFUN=4", response: okResponse()}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 4")}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 4")}, + {command: "AT+CFUN=1", response: okResponse()}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, + }} + manager, id := newStartedTestManager(t, client) + + enabled, err := manager.SetFlight(context.Background(), id, true) + if err != nil { + t.Fatalf("enable flight mode: %v", err) + } + if !enabled.Changed || enabled.PreviousMode != 1 || + enabled.CurrentMode != 4 || !enabled.FlightMode { + t.Fatalf("enable result = %#v", enabled) + } + disabled, err := manager.SetFlight(context.Background(), id, false) + if err != nil { + t.Fatalf("disable flight mode: %v", err) + } + if !disabled.Changed || disabled.PreviousMode != 4 || + disabled.CurrentMode != 1 || disabled.FlightMode { + t.Fatalf("disable result = %#v", disabled) + } + client.assertDone(t) +} + +func TestUSSDWaitsForAndDecodesCUSD(t *testing.T) { + client := &transcriptClient{ + steps: []clientStep{{ + command: `AT+CUSD=1,"*100#",15`, + response: okResponse(), + }}, + urcs: []string{`+CUSD: 0,"004F004B",72`}, + } + manager, id := newStartedTestManager(t, client) + + result, err := manager.USSD(context.Background(), id, "*100#") + if err != nil { + t.Fatalf("USSD: %v", err) + } + if result.Text != "OK" || result.Code != "*100#" || + result.DCS == nil || *result.DCS != 72 { + t.Fatalf("result = %#v", result) + } + if _, err := manager.USSD(context.Background(), id, "*100#\rAT"); err == nil { + t.Fatal("expected invalid service code rejection") + } + client.assertDone(t) +} diff --git a/internal/device/data.go b/internal/device/data.go new file mode 100644 index 0000000..eff8731 --- /dev/null +++ b/internal/device/data.go @@ -0,0 +1,292 @@ +package device + +import ( + "context" + "errors" + "fmt" + "regexp" + "strconv" + "strings" + + "vocat/internal/modem" +) + +var apnPattern = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,98}[A-Za-z0-9])?$`) + +func (manager *Manager) SetNetwork( + ctx context.Context, + id string, + request NetworkRequest, +) (NetworkResult, error) { + state, err := manager.lookup(id) + if err != nil { + return NetworkResult{}, err + } + apn := strings.TrimSpace(request.APN) + if request.Enabled && !apnPattern.MatchString(apn) { + return NetworkResult{}, ErrInvalidNetworkAPN + } + ipVersion := normalizeIPVersion(request.IPVersion) + if ipVersion == "" { + return NetworkResult{}, errors.New("IP version must be IP, IPV6, or IPV4V6") + } + + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return NetworkResult{}, err + } + if request.Enabled { + if err := manager.regionBlockError(state); err != nil { + manager.setResult(id, state, nil, err) + return NetworkResult{}, err + } + } + candidate := manager.candidateFor(state) + if candidate.QMIControl != "" && candidate.NetworkInterface != "" { + return setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion) + } + + client, err := manager.clientLocked(ctx, state, candidate) + if err != nil { + manager.setResult(id, state, nil, err) + return NetworkResult{}, err + } + if request.Enabled { + commands := []string{ + fmt.Sprintf(`AT+CGDCONT=1,"%s","%s"`, ipVersion, apn), + "AT+CGATT=1", + "AT+CGACT=1,1", + } + for _, command := range commands { + if _, err := manager.command(ctx, client, command); err != nil { + manager.setResult(id, state, nil, err) + return NetworkResult{}, err + } + } + } else { + if _, err := manager.command(ctx, client, "AT+CGACT=0,1"); err != nil { + manager.setResult(id, state, nil, err) + return NetworkResult{}, err + } + } + manager.setResult(id, state, nil, nil) + return NetworkResult{ + Enabled: request.Enabled, + Backend: "at", + Interface: candidate.NetworkInterface, + APN: apn, + IPVersion: ipVersion, + Detail: map[bool]string{true: "PDP context activated", false: "PDP context deactivated"}[request.Enabled], + }, nil +} + +func normalizeIPVersion(value string) string { + switch strings.ToUpper(strings.TrimSpace(value)) { + case "", "IP", "IPV4": + return "IP" + case "IPV6": + return "IPV6" + case "IPV4V6", "IPV6V4": + return "IPV4V6" + default: + return "" + } +} + +func (manager *Manager) USBNetMode(ctx context.Context, id string) (USBNetMode, error) { + response, err := manager.ExecuteAT(ctx, id, `AT+QCFG="usbnet"`) + if err != nil { + return USBNetMode{}, err + } + for _, line := range response.Lines { + upper := strings.ToUpper(strings.TrimSpace(line)) + if !strings.HasPrefix(upper, `+QCFG: "USBNET",`) { + continue + } + value := strings.TrimSpace(strings.TrimPrefix(upper, `+QCFG: "USBNET",`)) + mode, parseErr := strconv.Atoi(value) + if parseErr == nil { + return USBNetMode{Mode: mode, Name: usbNetModeName(mode)}, nil + } + } + return USBNetMode{}, errors.New("modem did not return a valid USB network mode") +} + +func (manager *Manager) SetUSBNetMode(ctx context.Context, id string, mode int) (USBNetMode, error) { + if mode < 0 || mode > 3 { + return USBNetMode{}, errors.New("USB network mode must be between 0 and 3") + } + response, err := manager.ExecuteSensitiveAT(ctx, id, fmt.Sprintf(`AT+QCFG="usbnet",%d`, mode)) + if err != nil { + return USBNetMode{}, err + } + if !response.OK() { + return USBNetMode{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines} + } + return USBNetMode{Mode: mode, Name: usbNetModeName(mode)}, nil +} + +// SetUSBNetModeByPort sets the USB network mode on a device that has only been +// discovered (not yet taken over), addressed by its AT port path. The port must +// belong to a currently discovered candidate, so the endpoint cannot be used to +// open arbitrary host paths. +func (manager *Manager) SetUSBNetModeByPort( + ctx context.Context, + atPortPath string, + mode int, +) (USBNetMode, error) { + if mode < 0 || mode > 3 { + return USBNetMode{}, errors.New("USB network mode must be between 0 and 3") + } + atPortPath = strings.TrimSpace(atPortPath) + if atPortPath == "" { + return USBNetMode{}, errors.New("an AT port path is required") + } + manager.mu.RLock() + var candidate modem.Candidate + found := false + for _, state := range manager.devices { + if state.discovered && + (state.candidate.ATPort.OpenPath() == atPortPath || state.candidate.ATPort.Path == atPortPath) { + candidate = copyCandidate(state.candidate) + found = true + break + } + } + manager.mu.RUnlock() + if !found { + return USBNetMode{}, fmt.Errorf("no discovered device owns AT port %q", atPortPath) + } + client, err := manager.opener.Open(ctx, candidate.ATPort) + if err != nil { + return USBNetMode{}, err + } + defer func() { _ = client.Close() }() + commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout) + defer cancel() + response, err := client.Execute(commandCtx, fmt.Sprintf(`AT+QCFG="usbnet",%d`, mode)) + if err != nil { + return USBNetMode{}, err + } + if !response.OK() { + return USBNetMode{}, &modem.CommandError{Command: response.Command, Final: response.Final, Lines: response.Lines} + } + return USBNetMode{Mode: mode, Name: usbNetModeName(mode)}, nil +} + +func usbNetModeName(mode int) string { + switch mode { + case 0: + return "QMI" + case 1: + return "ECM" + case 2: + return "MBIM" + case 3: + return "RNDIS" + default: + return "unknown" + } +} + +func (manager *Manager) OperatorSelection(ctx context.Context, id string) (OperatorSelection, error) { + response, err := manager.ExecuteAT(ctx, id, "AT+COPS?") + if err != nil { + return OperatorSelection{}, err + } + return parseOperatorSelection(response) +} + +func parseOperatorSelection(response modem.Response) (OperatorSelection, error) { + values := csvValues(valueAfterPrefix(response, "+COPS:")) + if len(values) < 1 { + return OperatorSelection{}, errors.New("modem did not return operator selection state") + } + result := OperatorSelection{} + result.Mode, _ = strconv.Atoi(values[0]) + if len(values) > 1 { + result.Format, _ = strconv.Atoi(values[1]) + } + if len(values) > 2 { + result.Operator = strings.Trim(values[2], `"`) + } + if len(values) > 3 { + result.AccessTechnology = accessTechnology(values[3]) + } + return result, nil +} + +func (manager *Manager) SetOperatorSelection( + ctx context.Context, + id string, + automatic bool, + plmn string, + accessTechnologyValue *int, +) (OperatorSelection, error) { + result := OperatorSelection{Mode: 0} + command := "AT+COPS=0" + if !automatic { + plmn = strings.TrimSpace(plmn) + if len(plmn) < 5 || len(plmn) > 6 || strings.IndexFunc(plmn, func(r rune) bool { return r < '0' || r > '9' }) >= 0 { + return OperatorSelection{}, errors.New("operator PLMN must contain 5 or 6 digits") + } + // Mode 1 is a real manual lock. Mode 4 is only a manual attempt with + // automatic fallback; using it made a rejected registration silently + // return to COPS=0 while the UI incorrectly reported a successful lock. + command = fmt.Sprintf(`AT+COPS=1,2,"%s"`, plmn) + actName := "" + if accessTechnologyValue != nil { + if *accessTechnologyValue < 0 || *accessTechnologyValue > 9 { + return OperatorSelection{}, errors.New("invalid operator access technology") + } + command += fmt.Sprintf(",%d", *accessTechnologyValue) + actName = accessTechnology(strconv.Itoa(*accessTechnologyValue)) + } + result = OperatorSelection{Mode: 1, Format: 2, Operator: plmn, AccessTechnology: actName} + } + state, err := manager.lookup(id) + if err != nil { + return OperatorSelection{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return OperatorSelection{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorSelection{}, err + } + // Manual PLMN selection makes the modem search for and register on the + // requested network, which can take tens of seconds — far longer than the + // normal command timeout. Use the same deadline budget as operator scan so + // the lock is not aborted while registration is still in progress. + lockCtx, cancel := manager.withTimeout(ctx, manager.scanTimeout) + defer cancel() + if _, err := client.Execute(lockCtx, command); err != nil { + manager.setResult(id, state, nil, errors.New("operator selection command failed")) + return OperatorSelection{}, err + } + if !automatic { + response, err := client.Execute(lockCtx, "AT+COPS?") + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorSelection{}, fmt.Errorf("verify manual operator selection: %w", err) + } + actual, err := parseOperatorSelection(response) + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorSelection{}, err + } + if actual.Mode != 1 || actual.Operator != plmn { + err := fmt.Errorf("network %s did not accept registration; modem reports mode=%d operator=%q", plmn, actual.Mode, actual.Operator) + manager.setResult(id, state, nil, err) + return OperatorSelection{}, err + } + result = actual + } + manager.setResult(id, state, nil, nil) + return result, nil +} diff --git a/internal/device/data_linux.go b/internal/device/data_linux.go new file mode 100644 index 0000000..2afdbef --- /dev/null +++ b/internal/device/data_linux.go @@ -0,0 +1,99 @@ +//go:build linux + +package device + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "vocat/internal/modem" +) + +func setQMINetwork( + ctx context.Context, + candidate modem.Candidate, + enabled bool, + apn string, + ipVersion string, +) (NetworkResult, error) { + qmiNetwork, err := exec.LookPath("qmi-network") + if err != nil { + return NetworkResult{}, fmt.Errorf("%w: install libqmi-utils to control %s", ErrDataBackendUnavailable, candidate.QMIControl) + } + profile, err := os.CreateTemp("", "vocat-qmi-*.conf") + if err != nil { + return NetworkResult{}, fmt.Errorf("create temporary QMI profile: %w", err) + } + profilePath := profile.Name() + defer os.Remove(profilePath) + ipType := map[string]string{"IP": "4", "IPV6": "6", "IPV4V6": "4"}[ipVersion] + if _, err := fmt.Fprintf(profile, "APN=%s\nIP_TYPE=%s\nPROXY=yes\n", apn, ipType); err != nil { + _ = profile.Close() + return NetworkResult{}, fmt.Errorf("write temporary QMI profile: %w", err) + } + if err := profile.Chmod(0o600); err != nil { + _ = profile.Close() + return NetworkResult{}, fmt.Errorf("protect temporary QMI profile: %w", err) + } + if err := profile.Close(); err != nil { + return NetworkResult{}, fmt.Errorf("close temporary QMI profile: %w", err) + } + + action := "stop" + if enabled { + action = "start" + } + command := exec.CommandContext(ctx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, action) + output, err := command.CombinedOutput() + detail := strings.TrimSpace(string(output)) + if err != nil { + lowerDetail := strings.ToLower(detail) + idempotentStop := !enabled && (strings.Contains(lowerDetail, "already stopped") || + strings.Contains(lowerDetail, "not started") || strings.Contains(lowerDetail, "no network")) + if !idempotentStop { + return NetworkResult{}, fmt.Errorf("qmi-network %s failed: %w: %s", action, err, detail) + } + } + if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil { + linkAction := "down" + if enabled { + linkAction = "up" + } + linkOutput, linkErr := exec.CommandContext(ctx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, linkAction).CombinedOutput() + if linkErr != nil { + return NetworkResult{}, fmt.Errorf("set %s %s: %w: %s", candidate.NetworkInterface, linkAction, linkErr, strings.TrimSpace(string(linkOutput))) + } + } + if enabled { + if busybox, lookErr := exec.LookPath("busybox"); lookErr == nil { + dhcpOutput, dhcpErr := exec.CommandContext(ctx, busybox, "udhcpc", "-q", "-n", "-t", "5", "-T", "3", "-i", candidate.NetworkInterface).CombinedOutput() + if dhcpErr != nil { + rollbackCtx, cancelRollback := context.WithTimeout(context.Background(), managerCommandCleanupTimeout) + defer cancelRollback() + _, _ = exec.CommandContext(rollbackCtx, qmiNetwork, "--profile="+profilePath, candidate.QMIControl, "stop").CombinedOutput() + if ipCommand, lookErr := exec.LookPath("ip"); lookErr == nil { + _, _ = exec.CommandContext(rollbackCtx, ipCommand, "link", "set", "dev", candidate.NetworkInterface, "down").CombinedOutput() + } + return NetworkResult{}, fmt.Errorf("QMI session started but DHCP failed: %w: %s", dhcpErr, strings.TrimSpace(string(dhcpOutput))) + } + if value := strings.TrimSpace(string(dhcpOutput)); value != "" { + detail = strings.TrimSpace(detail + "\n" + value) + } + } + } + return NetworkResult{ + Enabled: enabled, + Backend: "qmi", + Interface: candidate.NetworkInterface, + ControlDevice: candidate.QMIControl, + APN: apn, + IPVersion: ipVersion, + Detail: detail, + }, nil +} + +const managerCommandCleanupTimeout = 15 * time.Second diff --git a/internal/device/data_other.go b/internal/device/data_other.go new file mode 100644 index 0000000..9373b90 --- /dev/null +++ b/internal/device/data_other.go @@ -0,0 +1,20 @@ +//go:build !linux + +package device + +import ( + "context" + "fmt" + + "vocat/internal/modem" +) + +func setQMINetwork( + context.Context, + modem.Candidate, + bool, + string, + string, +) (NetworkResult, error) { + return NetworkResult{}, fmt.Errorf("%w: QMI control is supported only on Linux", ErrDataBackendUnavailable) +} diff --git a/internal/device/data_test.go b/internal/device/data_test.go new file mode 100644 index 0000000..27c424c --- /dev/null +++ b/internal/device/data_test.go @@ -0,0 +1,109 @@ +package device + +import ( + "context" + "errors" + "testing" +) + +func TestSetNetworkATBackendActivatesAndDeactivatesPDP(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: `AT+CGDCONT=1,"IPV4V6","internet"`, response: okResponse()}, + {command: "AT+CGATT=1", response: okResponse()}, + {command: "AT+CGACT=1,1", response: okResponse()}, + {command: "AT+CGACT=0,1", response: okResponse()}, + }} + manager, id := newStartedTestManager(t, client) + result, err := manager.SetNetwork(context.Background(), id, NetworkRequest{ + Enabled: true, APN: "internet", IPVersion: "IPV4V6", + }) + if err != nil { + t.Fatalf("enable network: %v", err) + } + if !result.Enabled || result.Backend != "at" || result.APN != "internet" { + t.Fatalf("enable result = %#v", result) + } + result, err = manager.SetNetwork(context.Background(), id, NetworkRequest{ + Enabled: false, APN: "internet", IPVersion: "IP", + }) + if err != nil { + t.Fatalf("disable network: %v", err) + } + if result.Enabled { + t.Fatalf("disable result = %#v", result) + } + client.assertDone(t) +} + +func TestSetNetworkRejectsUnsafeAPNBeforeOpeningModem(t *testing.T) { + client := &transcriptClient{} + manager, id := newStartedTestManager(t, client) + _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{ + Enabled: true, APN: `internet";AT+CFUN=0`, IPVersion: "IP", + }) + if !errors.Is(err, ErrInvalidNetworkAPN) { + t.Fatalf("error = %v, want ErrInvalidNetworkAPN", err) + } + client.assertDone(t) +} + +func TestUSBNetModeReadAndGuardedWrite(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: `AT+QCFG="usbnet"`, response: okResponse(`+QCFG: "usbnet",0`)}, + {command: `AT+QCFG="usbnet",1`, response: okResponse()}, + }} + manager, id := newStartedTestManager(t, client) + mode, err := manager.USBNetMode(context.Background(), id) + if err != nil { + t.Fatalf("read USB mode: %v", err) + } + if mode.Mode != 0 || mode.Name != "QMI" { + t.Fatalf("USB mode = %#v", mode) + } + mode, err = manager.SetUSBNetMode(context.Background(), id, 1) + if err != nil { + t.Fatalf("write USB mode: %v", err) + } + if mode.Mode != 1 || mode.Name != "ECM" { + t.Fatalf("new USB mode = %#v", mode) + } + client.assertDone(t) +} + +func TestOperatorSelectionManualAndAutomatic(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: `AT+COPS=1,2,"46000",7`, response: okResponse()}, + {command: "AT+COPS?", response: okResponse(`+COPS: 1,2,"46000",7`)}, + {command: "AT+COPS=0", response: okResponse()}, + }} + manager, id := newStartedTestManager(t, client) + act := 7 + selection, err := manager.SetOperatorSelection(context.Background(), id, false, "46000", &act) + if err != nil { + t.Fatalf("manual selection: %v", err) + } + if selection.Mode != 1 || selection.Format != 2 || selection.Operator != "46000" || selection.AccessTechnology != "LTE" { + t.Fatalf("manual selection = %#v", selection) + } + selection, err = manager.SetOperatorSelection(context.Background(), id, true, "", nil) + if err != nil { + t.Fatalf("automatic selection: %v", err) + } + if selection.Mode != 0 || selection.Operator != "" { + t.Fatalf("automatic selection = %#v", selection) + } + client.assertDone(t) +} + +func TestOperatorSelectionRejectsAutomaticFallbackAsSuccess(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: `AT+COPS=1,2,"46000",7`, response: okResponse()}, + {command: "AT+COPS?", response: okResponse("+COPS: 0")}, + }} + manager, id := newStartedTestManager(t, client) + act := 7 + if _, err := manager.SetOperatorSelection(context.Background(), id, false, "46000", &act); err == nil { + t.Fatal("manual selection must fail when readback returned automatic mode") + } + client.assertDone(t) +} diff --git a/internal/device/es9p.go b/internal/device/es9p.go new file mode 100644 index 0000000..0789fc8 --- /dev/null +++ b/internal/device/es9p.go @@ -0,0 +1,317 @@ +package device + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// es9pClient speaks SGP.22 ES9+ — JSON over HTTPS — to one SM-DP+. It is the +// network half of the LPA download flow: the host authenticates nothing itself +// (the eUICC does all certificate verification on-card); it only shuttles the +// base64 DER blobs between the SM-DP+ and the eUICC. +// +// The wire contract mirrors lpac's euicc/es9p.c: every request is a POST to +// https:///gsma/rsp2/es9plus/ with a fixed header set, binary +// fields base64-encoded, and the reply envelope carries the outcome in +// header.functionExecutionStatus (with statusCodeData.message holding the +// human-readable failure, e.g. "The matchingID is not found"). +type es9pClient struct { + smdp string + http *http.Client +} + +func newES9PClient(smdp string) *es9pClient { + // The eUICC — not the host — is the root of trust for RSP: during + // AuthenticateServer the card verifies the SM-DP+'s CERT.DPauth.SIG against + // its embedded CI root, so a rogue/TLS-MitM server cannot forge a signature + // the card will accept. The host TLS layer is transport only, and a minimal + // embedded box may ship no CA bundle (this is exactly what broke on the test + // machine), so we don't anchor host TLS to system roots. InsecureSkipVerify + // is safe here specifically because the card does the authoritative check. + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // eUICC is the RSP trust anchor + } + return &es9pClient{ + smdp: strings.TrimSpace(smdp), + http: &http.Client{Timeout: 90 * time.Second, Transport: transport}, + } +} + +// es9pError is a failed ES9+ functionExecutionStatus. Message is the SM-DP+'s +// own explanation (surfaced verbatim, as the reference implementation does). +type es9pError struct { + Function string + Status string + Message string + SubjectCode string + ReasonCode string +} + +func (e *es9pError) Error() string { + if e.Message != "" { + return e.Message + } + if mapped := es9pErrorMessage(e.SubjectCode, e.ReasonCode); mapped != "" { + return mapped + } + if e.Status != "" { + return fmt.Sprintf("SM-DP+ %s failed (%s)", e.Function, e.Status) + } + return fmt.Sprintf("SM-DP+ %s failed", e.Function) +} + +// es9pStatusCodeData mirrors header.functionExecutionStatus.statusCodeData. +type es9pStatusCodeData struct { + ReasonCode string `json:"reasonCode"` + SubjectCode string `json:"subjectCode"` + SubjectIdentifier string `json:"subjectIdentifier"` + Message string `json:"message"` +} + +// call POSTs one ES9+ function and returns the parsed top-level fields. Failure +// is decided the way lpac decides it: a non-success execution status, or a +// missing required output field, yields an es9pError carrying the SM-DP+ message. +func (c *es9pClient) call(ctx context.Context, function string, request map[string]string, requiredOut ...string) (map[string]json.RawMessage, error) { + url := "https://" + c.smdp + "/gsma/rsp2/es9plus/" + function + body, err := json.Marshal(request) + if err != nil { + return nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("User-Agent", "gsma-rsp-lpad") + httpReq.Header.Set("X-Admin-Protocol", "gsma/rsp/v2.2.2") + + resp, err := c.http.Do(httpReq) + if err != nil { + return nil, fmt.Errorf("es9p %s: %w", function, err) + } + defer resp.Body.Close() + data, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + if err != nil { + return nil, fmt.Errorf("es9p %s: read response: %w", function, err) + } + + var root map[string]json.RawMessage + if err := json.Unmarshal(data, &root); err != nil { + return nil, fmt.Errorf("es9p %s: invalid JSON (HTTP %d): %w", function, resp.StatusCode, err) + } + + var header struct { + FunctionExecutionStatus struct { + Status string `json:"status"` + StatusCodeData *es9pStatusCodeData `json:"statusCodeData"` + } `json:"functionExecutionStatus"` + } + if raw, ok := root["header"]; ok { + _ = json.Unmarshal(raw, &header) + } + fes := header.FunctionExecutionStatus + + // A non-success execution status is an outright failure. + switch fes.Status { + case "", "Executed-Success", "Executed-WithWarning": + // proceed + default: + return nil, es9pErrFromStatus(function, fes.Status, fes.StatusCodeData) + } + // Success means the expected output fields are present at the top level. + for _, key := range requiredOut { + if _, ok := root[key]; !ok { + return nil, es9pErrFromStatus(function, fes.Status, fes.StatusCodeData) + } + } + return root, nil +} + +func es9pErrFromStatus(function, status string, scd *es9pStatusCodeData) error { + err := &es9pError{Function: function, Status: status} + if scd != nil { + err.Message = scd.Message + err.SubjectCode = scd.SubjectCode + err.ReasonCode = scd.ReasonCode + } + return err +} + +// es9pErrorMessage maps an SGP.22 (subjectCode, reasonCode) pair to a +// human-readable failure when the SM-DP+ omits statusCodeData.message. Table +// mirrors lpac's euicc/es9p_errors.c. +var es9pErrorTable = map[[2]string]string{ + {"8.1", "4.8"}: "eUICC does not have sufficient space for this Profile", + {"8.1", "6.1"}: "eUICC signature is invalid or serverChallenge is invalid", + {"8.1.1", "2.2"}: "EID is missing in the context of this order", + {"8.1.1", "3.1"}: "a different EID is already associated with this ICCID", + {"8.1.1", "3.8"}: "EID doesn't match the expected value", + {"8.1.2", "6.1"}: "EUM Certificate is invalid", + {"8.1.2", "6.3"}: "EUM Certificate has expired", + {"8.1.3", "6.1"}: "eUICC Certificate is invalid", + {"8.1.3", "6.3"}: "eUICC Certificate has expired", + {"8.2", "1.2"}: "Profile has not yet been released", + {"8.2", "3.7"}: "BPP is not available for a new binding", + {"8.2.5", "3.7"}: "No more Profile available for the requested Profile Type", + {"8.2.5", "4.3"}: "No eligible Profile for this eUICC/Device", + {"8.2.6", "3.1"}: "a different MatchingID is associated with this ICCID", + {"8.2.6", "3.3"}: "Conflicting MatchingID value", + {"8.2.6", "3.8"}: "MatchingID (AC_Token or EventID) is refused", + {"8.2.7", "2.2"}: "Confirmation Code is missing", + {"8.2.7", "3.8"}: "Confirmation Code is refused", + {"8.2.7", "6.4"}: "maximum number of retries for the Confirmation Code exceeded", + {"8.8.1", "3.8"}: "Invalid SM-DP+ Address", + {"8.8.4", "3.7"}: "The SM-DP+ has no CERT.DPauth.ECDSA signed by one of the CI Public Key supported by the eUICC", + {"8.8.5", "4.1"}: "The Download order has expired", + {"8.8.5", "6.4"}: "maximum number of retries for the Profile download order exceeded", + {"8.10.1", "3.9"}: "The RSP session identified by the TransactionID is unknown", + {"8.11.1", "3.9"}: "Unknown CI Public Key. The CI used by the EUM Certificate is not a trusted root.", +} + +func es9pErrorMessage(subjectCode, reasonCode string) string { + return es9pErrorTable[[2]string{subjectCode, reasonCode}] +} + +// es9pString extracts a plain string field. +func es9pString(root map[string]json.RawMessage, key string) (string, error) { + raw, ok := root[key] + if !ok { + return "", fmt.Errorf("es9p: response missing %s", key) + } + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return "", fmt.Errorf("es9p: decode %s: %w", key, err) + } + return value, nil +} + +// es9pB64 extracts and base64-decodes a binary field. +func es9pB64(root map[string]json.RawMessage, key string) ([]byte, error) { + value, err := es9pString(root, key) + if err != nil { + return nil, err + } + return es9pBase64Decode(value) +} + +func es9pBase64Decode(value string) ([]byte, error) { + value = strings.TrimSpace(value) + if decoded, err := base64.StdEncoding.DecodeString(value); err == nil { + return decoded, nil + } + return base64.RawStdEncoding.DecodeString(value) +} + +func es9pBase64Encode(value []byte) string { + return base64.StdEncoding.EncodeToString(value) +} + +// es9pInitiateResult carries the server's half of mutual authentication. +type es9pInitiateResult struct { + TransactionID string + ServerSigned1 []byte + ServerSignature1 []byte + EuiccCiPKIDToBeUsed []byte + ServerCertificate []byte +} + +func (c *es9pClient) initiateAuthentication(ctx context.Context, euiccChallenge, euiccInfo1 []byte) (*es9pInitiateResult, error) { + root, err := c.call(ctx, "initiateAuthentication", map[string]string{ + "smdpAddress": c.smdp, + "euiccChallenge": es9pBase64Encode(euiccChallenge), + "euiccInfo1": es9pBase64Encode(euiccInfo1), + }, "transactionId", "serverSigned1", "serverSignature1", "euiccCiPKIdToBeUsed", "serverCertificate") + if err != nil { + return nil, err + } + result := &es9pInitiateResult{} + if result.TransactionID, err = es9pString(root, "transactionId"); err != nil { + return nil, err + } + if result.ServerSigned1, err = es9pB64(root, "serverSigned1"); err != nil { + return nil, err + } + if result.ServerSignature1, err = es9pB64(root, "serverSignature1"); err != nil { + return nil, err + } + if result.EuiccCiPKIDToBeUsed, err = es9pB64(root, "euiccCiPKIdToBeUsed"); err != nil { + return nil, err + } + if result.ServerCertificate, err = es9pB64(root, "serverCertificate"); err != nil { + return nil, err + } + return result, nil +} + +// es9pAuthenticateResult carries the profile metadata and the SM-DP+ download +// authorization needed for PrepareDownload. +type es9pAuthenticateResult struct { + TransactionID string + ProfileMetadata []byte + SmdpSigned2 []byte + SmdpSignature2 []byte + SmdpCertificate []byte +} + +func (c *es9pClient) authenticateClient(ctx context.Context, transactionID string, authenticateServerResponse []byte) (*es9pAuthenticateResult, error) { + root, err := c.call(ctx, "authenticateClient", map[string]string{ + "transactionId": transactionID, + "authenticateServerResponse": es9pBase64Encode(authenticateServerResponse), + }, "profileMetadata", "smdpSigned2", "smdpSignature2", "smdpCertificate") + if err != nil { + return nil, err + } + result := &es9pAuthenticateResult{TransactionID: transactionID} + if result.ProfileMetadata, err = es9pB64(root, "profileMetadata"); err != nil { + return nil, err + } + if result.SmdpSigned2, err = es9pB64(root, "smdpSigned2"); err != nil { + return nil, err + } + if result.SmdpSignature2, err = es9pB64(root, "smdpSignature2"); err != nil { + return nil, err + } + if result.SmdpCertificate, err = es9pB64(root, "smdpCertificate"); err != nil { + return nil, err + } + return result, nil +} + +func (c *es9pClient) getBoundProfilePackage(ctx context.Context, transactionID string, prepareDownloadResponse []byte) ([]byte, error) { + root, err := c.call(ctx, "getBoundProfilePackage", map[string]string{ + "transactionId": transactionID, + "prepareDownloadResponse": es9pBase64Encode(prepareDownloadResponse), + }, "boundProfilePackage") + if err != nil { + return nil, err + } + return es9pB64(root, "boundProfilePackage") +} + +// handleNotification delivers a pending notification (a ProfileInstallationResult +// for the download case). It is best-effort: the profile is already installed, so +// a notification failure is reported by the caller as a warning, not a failure. +func (c *es9pClient) handleNotification(ctx context.Context, pendingNotification []byte) error { + _, err := c.call(ctx, "handleNotification", map[string]string{ + "pendingNotification": es9pBase64Encode(pendingNotification), + }) + return err +} + +// cancelSession aborts an in-flight download so the SM-DP+ releases the +// transaction. Best-effort cleanup on error/abort paths. +func (c *es9pClient) cancelSession(ctx context.Context, transactionID string, cancelSessionResponse []byte) error { + _, err := c.call(ctx, "cancelSession", map[string]string{ + "transactionId": transactionID, + "cancelSessionResponse": es9pBase64Encode(cancelSessionResponse), + }) + return err +} diff --git a/internal/device/es9p_test.go b/internal/device/es9p_test.go new file mode 100644 index 0000000..832f964 --- /dev/null +++ b/internal/device/es9p_test.go @@ -0,0 +1,143 @@ +package device + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// newTestES9P routes an es9pClient at a throwaway TLS server. +func newTestES9P(t *testing.T, handler http.HandlerFunc) *es9pClient { + t.Helper() + server := httptest.NewTLSServer(handler) + t.Cleanup(server.Close) + client := newES9PClient(strings.TrimPrefix(server.URL, "https://")) + client.http = server.Client() + return client +} + +func successEnvelope(fields map[string]any) map[string]any { + env := map[string]any{ + "header": map[string]any{"functionExecutionStatus": map[string]any{"status": "Executed-Success"}}, + } + for key, value := range fields { + env[key] = value + } + return env +} + +func b64(value []byte) string { return base64.StdEncoding.EncodeToString(value) } + +func TestInitiateAuthenticationSuccess(t *testing.T) { + signed1 := []byte{0x30, 0x03, 0x80, 0x01, 0x09} + client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/gsma/rsp2/es9plus/initiateAuthentication" { + t.Errorf("path = %s", r.URL.Path) + } + if r.Header.Get("X-Admin-Protocol") != "gsma/rsp/v2.2.2" { + t.Errorf("X-Admin-Protocol = %q", r.Header.Get("X-Admin-Protocol")) + } + if r.Header.Get("User-Agent") != "gsma-rsp-lpad" { + t.Errorf("User-Agent = %q", r.Header.Get("User-Agent")) + } + var req map[string]string + _ = json.NewDecoder(r.Body).Decode(&req) + if req["smdpAddress"] == "" || req["euiccChallenge"] == "" || req["euiccInfo1"] == "" { + t.Errorf("missing request fields: %v", req) + } + _ = json.NewEncoder(w).Encode(successEnvelope(map[string]any{ + "transactionId": "dHJhbnNhY3Rpb24=", + "serverSigned1": b64(signed1), + "serverSignature1": b64([]byte{0x01, 0x02, 0x03}), + "euiccCiPKIdToBeUsed": b64([]byte{0x04, 0x05}), + "serverCertificate": b64([]byte{0x30, 0x01, 0x00}), + })) + }) + + result, err := client.initiateAuthentication(context.Background(), []byte{0x09, 0x09}, []byte{0x08, 0x08}) + if err != nil { + t.Fatalf("initiateAuthentication: %v", err) + } + if result.TransactionID != "dHJhbnNhY3Rpb24=" { + t.Errorf("transactionId = %q", result.TransactionID) + } + if !bytes.Equal(result.ServerSigned1, signed1) { + t.Errorf("serverSigned1 = %X", result.ServerSigned1) + } + if !bytes.Equal(result.EuiccCiPKIDToBeUsed, []byte{0x04, 0x05}) { + t.Errorf("euiccCiPKIdToBeUsed = %X", result.EuiccCiPKIDToBeUsed) + } +} + +// A Failed status with a server-supplied message surfaces that message verbatim. +func TestAuthenticateClientFailureMessage(t *testing.T) { + client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "header": map[string]any{"functionExecutionStatus": map[string]any{ + "status": "Failed", + "statusCodeData": map[string]string{ + "subjectCode": "8.2.6", "reasonCode": "3.8", "message": "The matchingID is not found", + }, + }}, + }) + }) + _, err := client.authenticateClient(context.Background(), "dA==", []byte{0x01}) + if err == nil || err.Error() != "The matchingID is not found" { + t.Fatalf("err = %v", err) + } + if code := ESIMDownloadErrorCode(err); code != "activation_code_refused" { + t.Fatalf("code = %q, want activation_code_refused", code) + } +} + +// A Failed status with only codes (no message) falls back to the SGP.22 table, +// and the insufficient-memory pair maps to the SPA's special error code. +func TestGetBoundProfilePackageInsufficientMemory(t *testing.T) { + client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "header": map[string]any{"functionExecutionStatus": map[string]any{ + "status": "Failed", + "statusCodeData": map[string]string{"subjectCode": "8.1", "reasonCode": "4.8"}, + }}, + }) + }) + _, err := client.getBoundProfilePackage(context.Background(), "dA==", []byte{0x01}) + if err == nil { + t.Fatalf("expected error") + } + if !strings.Contains(err.Error(), "sufficient space") { + t.Fatalf("err = %v, want table-supplied space message", err) + } + if code := ESIMDownloadErrorCode(err); code != "euicc_insufficient_memory" { + t.Fatalf("code = %q, want euicc_insufficient_memory", code) + } +} + +func TestGetBoundProfilePackageSuccess(t *testing.T) { + pkg := []byte{0xBF, 0x36, 0x05, 0x01, 0x02, 0x03, 0x04, 0x05} + client := newTestES9P(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/gsma/rsp2/es9plus/getBoundProfilePackage" { + t.Errorf("path = %s", r.URL.Path) + } + var req map[string]string + _ = json.NewDecoder(r.Body).Decode(&req) + if req["transactionId"] == "" || req["prepareDownloadResponse"] == "" { + t.Errorf("missing request fields: %v", req) + } + _ = json.NewEncoder(w).Encode(successEnvelope(map[string]any{ + "boundProfilePackage": b64(pkg), + })) + }) + got, err := client.getBoundProfilePackage(context.Background(), "dA==", []byte{0xAA}) + if err != nil { + t.Fatalf("getBoundProfilePackage: %v", err) + } + if !bytes.Equal(got, pkg) { + t.Fatalf("bpp = %X, want %X", got, pkg) + } +} diff --git a/internal/device/esim.go b/internal/device/esim.go new file mode 100644 index 0000000..5b9fa9e --- /dev/null +++ b/internal/device/esim.go @@ -0,0 +1,887 @@ +package device + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" + + "vocat/internal/i18n" + "vocat/internal/modem" +) + +// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough. +// +// The Quectel EC20's AT+CCHO logical-channel command is non-functional on the +// deployed firmware, so — like lpac's `at_csim` backend — we drive MANAGE +// CHANNEL / SELECT / STORE DATA manually over AT+CSIM. The logical channel is +// separate from the modem's own basic channel, so reading and switching +// profiles does not disturb the modem's network registration. +// +// Verified against a live eUICC: channel open/select/STORE DATA/close all +// succeed and GetProfilesInfo returns every profile with no authentication. + +// isdRAID is the standard ISD-R AID that hosts the LPA functions (ES10). +const isdRAID = "A0000005591010FFFFFFFF8900000100" + +// eSTK multi-SE products expose each eUICC storage through its own vendor +// ISD-R AID. The standard GSMA AID aliases one of them, so probing only that +// AID silently hides the second storage. +const ( + estkProductAID = "A06573746B6D65FFFFFFFFFFFF6D6774" + estkSE0AID = "A06573746B6D65FFFF4953442D522030" + estkSE1AID = "A06573746B6D65FFFF4953442D522031" +) + +func targetEuiccAID(aidHex string) string { + aidHex = strings.ToUpper(strings.TrimSpace(aidHex)) + if aidHex == "" { + return isdRAID + } + return aidHex +} + +var ( + errNoLogicalChannel = errors.New("esim: modem could not open a logical channel") + errNoEUICC = errors.New("esim: no eUICC (ISD-R) found on the inserted card") + errESIMSW = errors.New("esim: eUICC returned an error status word") + errESIMRecovering = errors.New("esim: profile-switch recovery is in progress") + errEUICCChannelStuck = errors.New("esim: eUICC APDU channel is unavailable until the modem restarts") +) + +// ErrNoEUICC is returned when the inserted card exposes no eUICC ISD-R, so the +// HTTP layer can render the empty state instead of an error. +var ErrNoEUICC = errNoEUICC + +// ErrEUICCChannelStuck means the modem kept rejecting MANAGE CHANNEL or +// SELECT ISD-R with the EC20's non-descriptive +CME ERROR: 0 after retries. +// This is observed after SIM hot-swap and requires a modem restart; repeating +// the same profile-list request cannot reset the baseband's UIM/APDU state. +var ErrEUICCChannelStuck = errEUICCChannelStuck + +// EsimProfile is one eUICC profile decoded from GetProfilesInfo. +type EsimProfile struct { + ICCID string `json:"iccid"` + AID string `json:"aidHex"` + ServiceProvider string `json:"serviceProviderName,omitempty"` + Name string `json:"name,omitempty"` + Nickname string `json:"nickname,omitempty"` + State int `json:"state"` // 0 = disabled, 1 = enabled + StateText string `json:"stateText"` + Class string `json:"classText,omitempty"` +} + +// EsimInfo is the decoded profile list plus chip metadata for one eUICC. +type EsimInfo struct { + EID string `json:"eid,omitempty"` + AID string `json:"aidHex,omitempty"` + Profiles []EsimProfile `json:"profiles"` +} + +// EsimInventoryEntry is one independently addressable eUICC storage together +// with its profile list and production metadata. +type EsimInventoryEntry struct { + Info EsimInfo + Chip EsimChipInfo +} + +// EnabledProfile returns the currently enabled profile, or nil. +func (info *EsimInfo) EnabledProfile() *EsimProfile { + for index := range info.Profiles { + if info.Profiles[index].State == 1 { + return &info.Profiles[index] + } + } + return nil +} + +// decodeICCID converts a GSM BCD (nibble-swapped) ICCID to its digit string. +func decodeICCID(raw []byte) string { + var builder strings.Builder + for _, b := range raw { + lo, hi := b&0x0F, b>>4 + if lo <= 9 { + builder.WriteByte(byte('0' + lo)) + } + if hi <= 9 { + builder.WriteByte(byte('0' + hi)) + } + } + return builder.String() +} + +// encodeFixedDigitBCD converts decimal digits to GSM BCD (nibble-swapped) and +// pads every unused nibble with F up to the requested fixed field size. +func encodeFixedDigitBCD(digits string, octets int, label string) ([]byte, error) { + digits = strings.TrimSpace(digits) + if digits == "" { + return nil, fmt.Errorf("esim: empty %s", label) + } + if octets <= 0 || len(digits) > octets*2 { + return nil, fmt.Errorf("esim: %s exceeds its %d-byte field", label, octets) + } + out := make([]byte, octets) + for index := range out { + out[index] = 0xFF + } + for index := 0; index < len(digits); index += 2 { + lo := digits[index] + if lo < '0' || lo > '9' { + return nil, fmt.Errorf("esim: invalid %s digit %q", label, lo) + } + // hiNibble is the high nibble value; a trailing odd digit pads with 0xF. + hiNibble := byte(0xF) + if index+1 < len(digits) { + hi := digits[index+1] + if hi < '0' || hi > '9' { + return nil, fmt.Errorf("esim: invalid %s digit %q", label, hi) + } + hiNibble = hi - '0' + } + out[index/2] = hiNibble<<4 | (lo - '0') + } + return out, nil +} + +// SGP.22 defines Iccid as the 10-octet EF-ICCID representation even when the +// printed identifier contains only 18 or 19 digits. +func encodeICCID(digits string) ([]byte, error) { + return encodeFixedDigitBCD(digits, 10, "ICCID") +} + +func buildEnableProfileRequest(iccid string) ([]byte, error) { + bcd, err := encodeICCID(iccid) + if err != nil { + return nil, err + } + profileID := derConstruct(0xA0, derEncode(0x5A, bcd)) + return derConstruct(0xBF31, profileID, derEncode(0x81, []byte{0xFF})), nil +} + +// parseCSIM extracts the payload and status word from an AT+CSIM response. +func parseCSIM(response modem.Response) ([]byte, int, error) { + value := valueAfterPrefix(response, "+CSIM:") + if value == "" { + return nil, 0, errors.New("esim: modem did not return a +CSIM result") + } + parts := csvValues(value) + if len(parts) < 2 { + return nil, 0, fmt.Errorf("esim: malformed +CSIM result %q", value) + } + hexData := strings.Trim(parts[1], `"`) + if len(hexData) < 4 { + return nil, 0, fmt.Errorf("esim: short +CSIM data %q", hexData) + } + raw, err := hex.DecodeString(hexData) + if err != nil { + return nil, 0, fmt.Errorf("esim: decode +CSIM data: %w", err) + } + sw := int(raw[len(raw)-2])<<8 | int(raw[len(raw)-1]) + return raw[:len(raw)-2], sw, nil +} + +// euiccChannel is an open logical channel to the eUICC's ISD-R. +type euiccChannel struct { + manager *Manager + id string + channel int +} + +// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage +// makes the eUICC decrypt/write sizeable SCP03t segments on-card, which can exceed +// the modem's default 3s command timeout, so eSIM APDUs get a longer budget. +const csimAPDUTimeout = 30 * time.Second + +// csim sends one raw APDU over AT+CSIM and returns payload + status word. +func (manager *Manager) csim(ctx context.Context, id string, apdu []byte) ([]byte, int, error) { + command := fmt.Sprintf("AT+CSIM=%d,\"%s\"", len(apdu)*2, strings.ToUpper(hex.EncodeToString(apdu))) + state, err := manager.lookup(id) + if err != nil { + return nil, 0, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return nil, 0, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + return nil, 0, err + } + // Give each eUICC APDU its own generous deadline (withTimeout preserves an + // existing one, so a shorter caller deadline still wins). + apduCtx, cancel := context.WithTimeout(ctx, csimAPDUTimeout) + defer cancel() + response, err := manager.command(apduCtx, client, command) + if err != nil { + return nil, 0, err + } + return parseCSIM(response) +} + +// openEuicc opens a logical channel and selects the ISD-R AID on it. +func (manager *Manager) openEuicc(ctx context.Context, id string) (*euiccChannel, error) { + return manager.openEuiccAID(ctx, id, isdRAID) +} + +func (manager *Manager) openEuiccAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) { + if manager.esimRecoveryActive(id) { + return nil, errESIMRecovering + } + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + channel, err := manager.openEuiccOnceAID(ctx, id, aidHex) + if err == nil { + return channel, nil + } + lastErr = err + if !isTransientEuiccCME(err) { + return nil, err + } + if attempt == 2 { + return nil, fmt.Errorf("%w: %v", ErrEUICCChannelStuck, err) + } + delay := time.Duration(attempt+1) * 250 * time.Millisecond + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(delay): + } + } + return nil, lastErr +} + +func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccChannel, error) { + return manager.openEuiccOnceAID(ctx, id, isdRAID) +} + +func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) { + // MANAGE CHANNEL (open): 00 70 00 00 01 -> " 90 00". This EC20 + // firmware requires the explicit one-byte expected length: Le=00 opens a + // channel but then rejects SELECT ISD-R at the AT+CSIM layer. + payload, sw, err := manager.csim(ctx, id, []byte{0x00, 0x70, 0x00, 0x00, 0x01}) + if err != nil { + return nil, err + } + if sw != 0x9000 || len(payload) != 1 { + return nil, errNoLogicalChannel + } + channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0])} + + // SELECT ISD-R by AID on the logical channel: CLA=channel, INS=A4, P1=04. + aidHex = strings.ToUpper(strings.TrimSpace(aidHex)) + aid, err := hex.DecodeString(aidHex) + if err != nil || len(aid) == 0 || len(aid) > 255 { + channel.close(context.Background()) + return nil, fmt.Errorf("esim: invalid ISD-R AID %q", aidHex) + } + selectAID := append([]byte{byte(channel.channel), 0xA4, 0x04, 0x00, byte(len(aid))}, aid...) + _, sw, err = manager.csim(ctx, id, selectAID) + if err != nil { + channel.close(context.Background()) + return nil, err + } + if sw>>8 == 0x61 { + // Drain the select FCP the card is holding with a proper GET RESPONSE + // (CLA=0x80|channel, INS=0xC0). transmit() injects the channel into the + // CLA low nibble, so the first byte here stays 0x80. + _, sw, _ = channel.transmit(ctx, []byte{0x80, 0xC0, 0x00, 0x00, byte(sw & 0xFF)}, 0x80) + } + if sw != 0x9000 { + channel.close(context.Background()) + return nil, errNoEUICC + } + return channel, nil +} + +// discoverEuiccAIDs detects eSTK multi-SE cards without changing any profile +// state. The vendor product applet is selected only as a read-only capability +// probe; when present, both vendor ISD-R AIDs are tried. Per OpenEUICC's eSTK +// integration, the generic GSMA AID is not appended after an eSTK SE opens, +// because it aliases one of the same storages. +func (manager *Manager) discoverEuiccAIDs(ctx context.Context, id string) []string { + product, err := manager.openEuiccAID(ctx, id, estkProductAID) + if err != nil { + return []string{isdRAID} + } + product.close(context.Background()) + + var found []string + for _, aid := range []string{estkSE0AID, estkSE1AID} { + channel, err := manager.openEuiccAID(ctx, id, aid) + if err != nil { + continue + } + channel.close(context.Background()) + found = append(found, aid) + } + if len(found) == 0 { + return []string{isdRAID} + } + return found +} + +func isTransientEuiccCME(err error) bool { + var commandErr *modem.CommandError + return errors.As(err, &commandErr) && + strings.EqualFold(strings.TrimSpace(commandErr.Final), "+CME ERROR: 0") +} + +// close releases the logical channel (MANAGE CHANNEL close). +func (channel *euiccChannel) close(ctx context.Context) { + closeAPDU := []byte{0x00, 0x70, 0x80, byte(channel.channel), 0x00} + _, _, _ = channel.manager.csim(ctx, channel.id, closeAPDU) +} + +// transmit sends one APDU on the logical channel (CLA high nibble from insClass, +// channel number in the low nibble), following 61xx "more data" continuations, +// and returns the assembled payload. +func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass byte) ([]byte, int, error) { + apdu[0] = (apdu[0] & 0xF0) | byte(channel.channel) + payload, sw, err := channel.manager.csim(ctx, channel.id, apdu) + if err != nil { + return nil, 0, err + } + assembled := append([]byte(nil), payload...) + guard := 0 + for sw>>8 == 0x61 && guard < 24 { + guard++ + getResponse := []byte{0x80 | byte(channel.channel), 0xC0, 0x00, 0x00, byte(sw & 0xFF)} + frag, nextSW, err := channel.manager.csim(ctx, channel.id, getResponse) + if err != nil { + return nil, 0, err + } + assembled = append(assembled, frag...) + sw = nextSW + } + return assembled, sw, nil +} + +// es10 runs one ES10 command: it wraps the DER request body in one or more +// chained STORE DATA APDUs (see storeDataChained) and returns the assembled +// response body. Small requests produce a single P1=0x91/P2=0x00 block, exactly +// as before; larger ones (AuthenticateServer, LoadBoundProfilePackage, …) are +// split across continuation blocks. +func (channel *euiccChannel) es10(ctx context.Context, derRequest []byte) ([]byte, error) { + return channel.storeDataChained(ctx, derRequest) +} + +// derNode is one decoded BER-TLV element (long-form tags and lengths handled). +type derNode struct { + tag int + value []byte + children []*derNode +} + +// derParse decodes a sequence of BER-TLV elements. Constructed elements have +// their value recursively decoded into children. +func derParse(data []byte) []*derNode { + var nodes []*derNode + index := 0 + for index < len(data) { + node, next, ok := derDecodeOne(data, index) + if !ok { + break + } + nodes = append(nodes, node) + index = next + } + return nodes +} + +func derDecodeOne(data []byte, start int) (*derNode, int, bool) { + index := start + if index >= len(data) { + return nil, 0, false + } + first := data[index] + index++ + constructed := first&0x20 != 0 + tag := int(first) + if first&0x1F == 0x1F { // long-form tag: keep the full tag bytes (e.g. 9F70, BF2D) + for index < len(data) { + b := data[index] + index++ + tag = tag<<8 | int(b) + if b&0x80 == 0 { + break + } + } + } + if index >= len(data) { + return nil, 0, false + } + lengthByte := data[index] + index++ + length := 0 + if lengthByte&0x80 == 0 { + length = int(lengthByte) + } else { + count := int(lengthByte & 0x7F) + if count == 0 || count > 4 || index+count > len(data) { + return nil, 0, false + } + for i := 0; i < count; i++ { + length = length<<8 | int(data[index]) + index++ + } + } + if index+length > len(data) { + return nil, 0, false + } + value := data[index : index+length] + node := &derNode{tag: tag, value: value} + if constructed { + node.children = derParse(value) + } + return node, index + length, true +} + +// derValue returns the raw value of the first node with tag. +func derValue(nodes []*derNode, tag int) []byte { + for _, node := range nodes { + if node.tag == tag { + return node.value + } + } + return nil +} + +// derFindAll recursively collects every node with the given tag. Icons live in +// primitive (non-constructed) leaves, so their bytes are never descended into. +func derFindAll(nodes []*derNode, tag int) []*derNode { + var found []*derNode + for _, node := range nodes { + if node.tag == tag { + found = append(found, node) + } + found = append(found, derFindAll(node.children, tag)...) + } + return found +} + +// parseProfilesInfo decodes a GetProfilesInfo response body into profiles. The +// ProfileInfo records (tag E3) are collected wherever they sit (some cards use +// a BF3D root, others echo BF2D, with an optional A0 list wrapper). +func parseProfilesInfo(payload []byte) []EsimProfile { + records := derFindAll(derParse(payload), 0xE3) + var profiles []EsimProfile + seenICCID := make(map[string]struct{}) + for _, record := range records { + fields := record.children + profile := EsimProfile{ + ServiceProvider: string(derValue(fields, 0x91)), + Name: string(derValue(fields, 0x92)), + Nickname: string(derValue(fields, 0x90)), + } + if iccid := derValue(fields, 0x5A); iccid != nil { + profile.ICCID = decodeICCID(iccid) + } + // E3 is reused by constructed metadata inside some eUICC 4.x profile + // records. Recursive discovery is needed for cards that wrap the real + // ProfileInfo list, but those nested E3 nodes are not profiles and carry + // no ICCID. Never expose an entry that cannot be safely addressed by the + // ES10c profile operations; also collapse duplicate ICCIDs defensively. + if !validProfileICCID(profile.ICCID) { + continue + } + if _, exists := seenICCID[profile.ICCID]; exists { + continue + } + seenICCID[profile.ICCID] = struct{}{} + if aid := derValue(fields, 0x4F); aid != nil { + profile.AID = strings.ToUpper(hex.EncodeToString(aid)) + } + if state := derValue(fields, 0x9F70); len(state) == 1 { + profile.State = int(state[0]) + } + profile.StateText = i18n.T("已禁用") + if profile.State == 1 { + profile.StateText = i18n.T("已启用") + } + if class := derValue(fields, 0x95); len(class) == 1 { + profile.Class = map[int]string{0: "test", 1: "provisioning", 2: "operational"}[int(class[0])] + } + profiles = append(profiles, profile) + } + return profiles +} + +func validProfileICCID(iccid string) bool { + if len(iccid) < 18 || len(iccid) > 20 || !strings.HasPrefix(iccid, "89") { + return false + } + for _, character := range iccid { + if character < '0' || character > '9' { + return false + } + } + return true +} + +// ESIMListProfiles reads the eUICC profile list via ES10c GetProfilesInfo. +func (manager *Manager) ESIMListProfiles(ctx context.Context, id string) (EsimInfo, error) { + manager.esimMu.Lock() + defer manager.esimMu.Unlock() + if manager.esimRecoveryActive(id) { + if cached, ok := manager.cachedESIMInfo(id); ok { + return cached, nil + } + return EsimInfo{}, errESIMRecovering + } + channel, err := manager.openEuicc(ctx, id) + if err != nil { + return EsimInfo{}, err + } + defer channel.close(context.Background()) + payload, err := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) // GetProfilesInfo + if err != nil { + return EsimInfo{}, err + } + info := EsimInfo{Profiles: parseProfilesInfo(payload)} + manager.cacheESIMInfo(id, info) + return info, nil +} + +// ESIMSwitchProfile enables one profile by ICCID via ES10c EnableProfile. +func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid string, aidHex string) error { + iccid = strings.TrimSpace(iccid) + if iccid == "" { + return errors.New("esim: an ICCID is required") + } + der, err := buildEnableProfileRequest(iccid) + if err != nil { + return err + } + manager.esimMu.Lock() + if err := manager.waitForESIMRecovery(ctx, id); err != nil { + manager.esimMu.Unlock() + return err + } + channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex)) + if err != nil { + manager.esimMu.Unlock() + return err + } + + // EnableProfile request (SGP.22 ES10c, per lpac): + // BF31 { A0 { 5A } 81 01 FF } (refresh = yes) + // The profileIdentifier is an explicitly-tagged [0] CHOICE, so the ICCID + // element (5A) must be wrapped in A0 — omitting that wrapper makes the eUICC + // reject the command with result 0x7F (undefined error). refreshFlag (81) + // stays a sibling of A0, directly under BF31. + // EnableProfile is a non-idempotent commit. Once its APDU starts, a browser + // disconnect or reverse-proxy timeout must not cancel it halfway through and + // skip the modem reset, otherwise EC20 remains in SIM failure (+CME 13). + commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout) + payload, err := channel.es10(commitContext, der) + cancelCommit() + // Release the logical channel before any reset: openEuicc's csim holds + // opMu only for the duration of each APDU, so by here the lock is free. + closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout) + channel.close(closeContext) + cancelClose() + if err != nil { + // The card may have committed immediately before the transport error. A + // detached reset is safe in either case and prevents an uncertain switch + // from leaving the modem's SIM cache unusable. + manager.startProfileSwitchRecovery(id) + manager.esimMu.Unlock() + return err + } + // A transport SW 9000 only means the APDU reached the eUICC. The real outcome + // is the EnableProfile result code (tag 80) inside the BF31 response — honour + // it so a rejected switch is surfaced instead of reported as "switched". + result, ok := enableProfileResult(payload) + if !ok { + manager.startProfileSwitchRecovery(id) + manager.esimMu.Unlock() + return fmt.Errorf("esim: unexpected EnableProfile response %s", strings.ToUpper(hex.EncodeToString(payload))) + } + if err := enableProfileResponseError(byte(result), payload); err != nil { + manager.esimMu.Unlock() + return err + } + manager.markCachedProfileEnabled(id, iccid) + // The eUICC accepted the target profile. Reset and repopulate the modem in + // a detached recovery so it survives an HTTP disconnect, but keep this API + // call pending until the live modem ICCID proves that the switch took effect. + manager.startProfileSwitchRecovery(id) + manager.esimMu.Unlock() + + verifyContext, cancelVerify := context.WithTimeout(context.WithoutCancel(ctx), profileSwitchVerificationTimeout(manager)) + defer cancelVerify() + if err := manager.waitForESIMRecovery(verifyContext, id); err != nil { + return err + } + return manager.verifySwitchedICCID(verifyContext, id, iccid) +} + +func (manager *Manager) startProfileSwitchRecovery(id string) { + done := make(chan struct{}) + manager.esimRecoveryMu.Lock() + if manager.esimRecoveries == nil { + manager.esimRecoveries = make(map[string]chan struct{}) + } + if manager.esimRecoveries[id] != nil { + manager.esimRecoveryMu.Unlock() + return + } + manager.esimRecoveries[id] = done + manager.esimRecoveryMu.Unlock() + go func() { + manager.recoverAfterProfileSwitch(id) + manager.esimRecoveryMu.Lock() + if manager.esimRecoveries[id] == done { + delete(manager.esimRecoveries, id) + close(done) + } + manager.esimRecoveryMu.Unlock() + }() +} + +func (manager *Manager) waitForESIMRecovery(ctx context.Context, id string) error { + manager.esimRecoveryMu.Lock() + done := manager.esimRecoveries[id] + manager.esimRecoveryMu.Unlock() + if done == nil { + return nil + } + select { + case <-done: + return nil + case <-ctx.Done(): + return fmt.Errorf("esim: wait for profile-switch recovery: %w", ctx.Err()) + } +} + +func (manager *Manager) esimRecoveryActive(id string) bool { + manager.esimRecoveryMu.Lock() + active := manager.esimRecoveries[id] != nil + manager.esimRecoveryMu.Unlock() + return active +} + +func cloneESIMInfo(info EsimInfo) EsimInfo { + info.Profiles = append([]EsimProfile(nil), info.Profiles...) + return info +} + +func (manager *Manager) cachedESIMInfo(id string) (EsimInfo, bool) { + manager.esimCacheMu.RLock() + info, ok := manager.esimCache[id] + manager.esimCacheMu.RUnlock() + return cloneESIMInfo(info), ok +} + +func (manager *Manager) cacheESIMInfo(id string, info EsimInfo) { + manager.esimCacheMu.Lock() + manager.esimCache[id] = cloneESIMInfo(info) + manager.esimCacheMu.Unlock() +} + +func (manager *Manager) markCachedProfileEnabled(id, iccid string) { + manager.esimCacheMu.Lock() + info, ok := manager.esimCache[id] + if ok { + for index := range info.Profiles { + if info.Profiles[index].ICCID == iccid { + info.Profiles[index].State = 1 + info.Profiles[index].StateText = i18n.T("已启用") + } else { + info.Profiles[index].State = 0 + info.Profiles[index].StateText = i18n.T("已禁用") + } + } + manager.esimCache[id] = info + } + manager.esimCacheMu.Unlock() +} + +func (manager *Manager) markCachedProfileDisabled(id, iccid string) { + manager.esimCacheMu.Lock() + info, ok := manager.esimCache[id] + if ok { + for index := range info.Profiles { + if info.Profiles[index].ICCID == iccid { + info.Profiles[index].State = 0 + info.Profiles[index].StateText = i18n.T("已禁用") + break + } + } + manager.esimCache[id] = info + } + manager.esimCacheMu.Unlock() +} + +func (manager *Manager) removeCachedProfile(id, iccid string) { + manager.esimCacheMu.Lock() + info, ok := manager.esimCache[id] + if ok { + profiles := info.Profiles[:0] + for _, profile := range info.Profiles { + if profile.ICCID != iccid { + profiles = append(profiles, profile) + } + } + info.Profiles = profiles + manager.esimCache[id] = info + } + manager.esimCacheMu.Unlock() +} + +func (manager *Manager) renameCachedProfile(id, iccid, nickname string) { + manager.esimCacheMu.Lock() + info, ok := manager.esimCache[id] + if ok { + for index := range info.Profiles { + if info.Profiles[index].ICCID == iccid { + info.Profiles[index].Nickname = nickname + break + } + } + manager.esimCache[id] = info + } + manager.esimCacheMu.Unlock() +} + +// recoverAfterProfileSwitch owns the post-commit reset independently of the +// initiating HTTP request. EC20 commonly drops the AT port while processing +// CFUN=1,1, so the reset error is intentionally followed by discovery retries. +func (manager *Manager) recoverAfterProfileSwitch(id string) { + resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout) + _ = manager.rebootForProfileSwitch(resetContext, id) + cancelReset() + manager.refreshAfterProfileSwitch(id) +} + +// refreshAfterProfileSwitch repopulates the device snapshot in the background +// after an eSIM profile switch + modem reboot. /overview only serves the cached +// snapshot, and nothing else live-reads post-switch, so without this the card +// stays on "--" forever. The EC20 takes ~10-15s to come back from AT+CFUN=1,1, +// so we delay first, then retry with backoff. Transport errors during the +// reboot window are fine — Fix 1 discards the poisoned client and reopens on +// the next attempt. All errors are swallowed: this is best-effort self-healing +// and setResult already records the last failure for the UI. +func (manager *Manager) refreshAfterProfileSwitch(id string) { + const ( + settle = 8 * time.Second + interval = 4 * time.Second + attempts = 6 + ) + time.Sleep(settle) + for attempt := 0; attempt < attempts; attempt++ { + ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4) + _, err := manager.Refresh(ctx, id) + cancel() + if err == nil { + return + } + time.Sleep(interval) + } +} + +// enableProfileResult extracts the EnableProfile result code (tag 80) from the +// ES10c response body. ok is false when no result code is present. +func enableProfileResult(payload []byte) (int, bool) { + for _, node := range derFindAll(derParse(payload), 0x80) { + if len(node.value) > 0 { + return int(node.value[0]), true + } + } + return 0, false +} + +var ( + ErrESIMEnableProfileNotFound = errors.New("esim: profile to enable was not found on the selected eUICC") + ErrESIMProfileNotDisabled = errors.New("esim: profile is not currently disabled") + ErrESIMEnableDisallowedPolicy = errors.New("esim: profile switch is not allowed by the active profile policy") + ErrESIMWrongProfileReenabling = errors.New("esim: profile cannot be re-enabled from the current profile state") + ErrESIMEnableCATBusy = errors.New("esim: card application toolkit is busy; retry enabling later") + ErrESIMEnableUndefined = errors.New("esim: eUICC returned undefinedError while enabling this profile; the card did not provide a more specific reason") +) + +// enableProfileResponseError maps the complete SGP.22 EnableProfileResult +// enumeration. In particular, 0x7F is undefinedError: it is a definite card +// rejection, but it does not prove that the subscription itself is unusable. +func enableProfileResponseError(result byte, payload []byte) error { + raw := strings.ToUpper(hex.EncodeToString(payload)) + wrap := func(cause error) error { + return fmt.Errorf("%w (result=0x%02X, raw %s)", cause, result, raw) + } + switch result { + case 0: + return nil + case 1: + return wrap(ErrESIMEnableProfileNotFound) + case 2: + return wrap(ErrESIMProfileNotDisabled) + case 3: + return wrap(ErrESIMEnableDisallowedPolicy) + case 4: + return wrap(ErrESIMWrongProfileReenabling) + case 5: + return wrap(ErrESIMEnableCATBusy) + case 0x7F: + return wrap(ErrESIMEnableUndefined) + default: + return fmt.Errorf("esim: eUICC rejected EnableProfile, result=0x%02X (raw %s)", result, raw) + } +} + +func profileSwitchVerificationTimeout(manager *Manager) time.Duration { + // A slow EC20 can spend one long command timeout resetting, then several + // snapshot attempts reopening its USB serial port. Keep the HTTP operation + // alive for that recovery, with a practical floor for unusually slow hosts. + timeout := manager.longTimeout*2 + 90*time.Second + if timeout < 2*time.Minute { + return 2 * time.Minute + } + return timeout +} + +// verifySwitchedICCID performs a fresh baseband read after recovery. An ES10c +// result of zero only means the eUICC accepted the operation; the state change +// is finalized by REFRESH/reset. The UI must not report success until the modem +// is actually exposing the requested ICCID. +func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected string) error { + expected = strings.TrimSpace(expected) + const attempts = 6 + var lastICCID string + var lastErr error + for attempt := 0; attempt < attempts; attempt++ { + for _, command := range []string{"AT+CCID", "AT+QCCID"} { + commandContext, cancel := context.WithTimeout(ctx, manager.commandTimeout) + response, err := manager.ExecuteAT(commandContext, id, command) + cancel() + if err != nil { + lastErr = err + continue + } + live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22) + if live == "" { + lastErr = errors.New("modem response contained no valid ICCID") + continue + } + lastICCID = live + if live == expected { + return nil + } + lastErr = fmt.Errorf("modem still reports ICCID %s", live) + break + } + if attempt+1 < attempts { + select { + case <-time.After(2 * time.Second): + case <-ctx.Done(): + return fmt.Errorf("esim: verify enabled profile %s: %w", expected, ctx.Err()) + } + } + } + if lastICCID != "" { + return fmt.Errorf("esim: EnableProfile was accepted but target ICCID %s did not become active after modem recovery (current ICCID %s)", expected, lastICCID) + } + return fmt.Errorf("esim: EnableProfile was accepted but target ICCID %s could not be verified after modem recovery: %w", expected, lastErr) +} diff --git a/internal/device/esim_delete.go b/internal/device/esim_delete.go new file mode 100644 index 0000000..8da72a2 --- /dev/null +++ b/internal/device/esim_delete.go @@ -0,0 +1,113 @@ +package device + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +var ( + // These errors mirror the standardized SGP.22 DeleteProfileResponse values. + // Keep them exported so the HTTP layer can return actionable API errors + // instead of leaking raw BER-TLV response bytes to the UI. + ErrESIMDeleteProfileNotFound = errors.New("esim: profile was not found on the eUICC") + ErrESIMDeleteProfileNotDisabled = errors.New("esim: the active profile cannot be deleted; enable another profile first") + ErrESIMDeleteDisallowedByPolicy = errors.New("esim: profile deletion is not allowed by its policy") +) + +// EsimDeleteResult reports storage reclaimed by a successful ES10c delete. +type EsimDeleteResult struct { + SpaceDelta int64 + Warning string +} + +func buildDeleteProfileRequest(iccid string) ([]byte, error) { + bcd, err := encodeICCID(strings.TrimSpace(iccid)) + if err != nil { + return nil, err + } + // SGP.22 ES10c DeleteProfileRequest: BF33 { 5A }. + return derConstruct(0xBF33, derEncode(0x5A, bcd)), nil +} + +func deleteProfileResult(payload []byte) (byte, bool) { + nodes := derParse(payload) + if len(nodes) != 1 || nodes[0].tag != 0xBF33 { + return 0, false + } + result := derFindValue(payload, 0x80) + if len(result) != 1 { + return 0, false + } + return result[0], true +} + +func deleteProfileResponseError(result byte, payload []byte) error { + raw := strings.ToUpper(hex.EncodeToString(payload)) + switch result { + case 0: + return nil + case 1: + return fmt.Errorf("%w (result=0x%02X, raw %s)", ErrESIMDeleteProfileNotFound, result, raw) + case 2: + return fmt.Errorf("%w (result=0x%02X, raw %s)", ErrESIMDeleteProfileNotDisabled, result, raw) + case 3: + return fmt.Errorf("%w (result=0x%02X, raw %s)", ErrESIMDeleteDisallowedByPolicy, result, raw) + default: + return fmt.Errorf("esim: eUICC rejected DeleteProfile, result=0x%02X (raw %s)", result, raw) + } +} + +// ESIMDeleteProfile removes one installed, disabled profile through ES10c. +// Root CI certificates are not involved in local profile management; the +// eUICC authorizes this operation through its ISD-R interface. +func (manager *Manager) ESIMDeleteProfile(ctx context.Context, id, iccid, aidHex string) (*EsimDeleteResult, error) { + request, err := buildDeleteProfileRequest(iccid) + if err != nil { + return nil, err + } + manager.esimMu.Lock() + defer manager.esimMu.Unlock() + if err := manager.waitForESIMRecovery(ctx, id); err != nil { + return nil, err + } + channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex)) + if err != nil { + return nil, err + } + defer channel.close(context.Background()) + + freeBefore, beforeKnown := 0, false + if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil { + freeBefore, beforeKnown = euiccFreeNVRAM(info2) + } + + // DeleteProfile is non-idempotent. Once submitted, finish reading the card's + // result even if the browser request is cancelled. + commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout) + payload, err := channel.es10(commitContext, request) + cancelCommit() + if err != nil { + return nil, err + } + result, ok := deleteProfileResult(payload) + if !ok { + return nil, fmt.Errorf("esim: unexpected DeleteProfile response %s", strings.ToUpper(hex.EncodeToString(payload))) + } + if err := deleteProfileResponseError(result, payload); err != nil { + return nil, err + } + + deleted := &EsimDeleteResult{} + if info2, infoErr := channel.getEUICCInfo2(ctx); infoErr == nil { + if freeAfter, afterKnown := euiccFreeNVRAM(info2); beforeKnown && afterKnown && freeAfter >= freeBefore { + deleted.SpaceDelta = int64(freeAfter - freeBefore) + } + } else { + deleted.Warning = "Profile was deleted, but reclaimed storage could not be read" + } + manager.removeCachedProfile(id, strings.TrimSpace(iccid)) + return deleted, nil +} diff --git a/internal/device/esim_der.go b/internal/device/esim_der.go new file mode 100644 index 0000000..68f0333 --- /dev/null +++ b/internal/device/esim_der.go @@ -0,0 +1,117 @@ +package device + +import "fmt" + +// DER/BER-TLV encoding helpers — the encoder counterpart to the decoder +// (derParse/derDecodeOne) in esim.go. These build the ES10 request bodies the +// eUICC consumes (AuthenticateServer, PrepareDownload, LoadBoundProfilePackage, +// …), whose tags mix one-byte (0x30, 0x04) and two-byte (0x5F37, 0xBF38) forms +// and whose payloads can exceed the 0x80 short-length threshold. + +// derEncodeTag emits a tag's identifier bytes (1 byte for tags < 0x100, more for +// long-form tags such as 0x5F37 or 0xBF38). +func derEncodeTag(tag int) []byte { + if tag < 0x100 { + return []byte{byte(tag)} + } + out := make([]byte, 0, 3) + started := false + for shift := 24; shift >= 0; shift -= 8 { + b := byte(tag >> shift) + if b != 0 || started { + out = append(out, b) + started = true + } + } + return out +} + +// derEncodeLength emits a length in short form (< 0x80) or long form (0x81/0x82/0x83). +func derEncodeLength(length int) []byte { + switch { + case length < 0x80: + return []byte{byte(length)} + case length < 0x100: + return []byte{0x81, byte(length)} + case length < 0x10000: + return []byte{0x82, byte(length >> 8), byte(length)} + default: + return []byte{0x83, byte(length >> 16), byte(length >> 8), byte(length)} + } +} + +// derEncode builds one complete BER-TLV element: tag + length + value. +func derEncode(tag int, value []byte) []byte { + out := derEncodeTag(tag) + out = append(out, derEncodeLength(len(value))...) + return append(out, value...) +} + +// derConstruct builds a constructed element whose value is the concatenation of +// already-encoded child elements. +func derConstruct(tag int, children ...[]byte) []byte { + var value []byte + for _, child := range children { + value = append(value, child...) + } + return derEncode(tag, value) +} + +// derFindValue parses data and returns the value of the first node with tag, +// searching recursively. Use this (not derValue) when the target may be nested +// inside an enclosing element — e.g. transactionId (0x80) inside serverSigned1. +func derFindValue(data []byte, tag int) []byte { + nodes := derFindAll(derParse(data), tag) + if len(nodes) == 0 { + return nil + } + return nodes[0].value +} + +// derElementAt decodes the single BER-TLV element starting at buf[offset] and +// reports its tag, the number of header bytes (tag + length), and the total +// element length (header + value). It performs no recursion, so callers can walk +// a buffer by explicit offset — exactly what LoadBoundProfilePackage segmentation +// needs to slice the package at TLV boundaries. +func derElementAt(buf []byte, offset int) (tag int, headerLen int, totalLen int, err error) { + index := offset + if index >= len(buf) { + return 0, 0, 0, fmt.Errorf("esim: element at %d out of range", offset) + } + first := buf[index] + index++ + tag = int(first) + if first&0x1F == 0x1F { // long-form tag + for index < len(buf) { + b := buf[index] + index++ + tag = tag<<8 | int(b) + if b&0x80 == 0 { + break + } + } + } + if index >= len(buf) { + return 0, 0, 0, fmt.Errorf("esim: truncated tag at %d", offset) + } + lengthByte := buf[index] + index++ + length := 0 + if lengthByte&0x80 == 0 { + length = int(lengthByte) + } else { + count := int(lengthByte & 0x7F) + if count == 0 || count > 4 || index+count > len(buf) { + return 0, 0, 0, fmt.Errorf("esim: bad length at %d", offset) + } + for i := 0; i < count; i++ { + length = length<<8 | int(buf[index]) + index++ + } + } + headerLen = index - offset + if headerLen+length > len(buf)-offset { + return 0, 0, 0, fmt.Errorf("esim: element at %d overruns buffer", offset) + } + return tag, headerLen, headerLen + length, nil +} diff --git a/internal/device/esim_der_test.go b/internal/device/esim_der_test.go new file mode 100644 index 0000000..337d1b0 --- /dev/null +++ b/internal/device/esim_der_test.go @@ -0,0 +1,95 @@ +package device + +import ( + "bytes" + "testing" +) + +func TestDerEncodeShortForm(t *testing.T) { + got := derEncode(0x30, []byte{0x01, 0x02, 0x03}) + want := []byte{0x30, 0x03, 0x01, 0x02, 0x03} + if !bytes.Equal(got, want) { + t.Fatalf("derEncode short = %X, want %X", got, want) + } +} + +func TestDerEncodeLongFormTag(t *testing.T) { + got := derEncode(0x5F37, []byte{0xAA}) + want := []byte{0x5F, 0x37, 0x01, 0xAA} + if !bytes.Equal(got, want) { + t.Fatalf("derEncode long tag = %X, want %X", got, want) + } + // Two-byte constructed tag used by every ES10 request. + got = derEncode(0xBF38, nil) + want = []byte{0xBF, 0x38, 0x00} + if !bytes.Equal(got, want) { + t.Fatalf("derEncode BF38 = %X, want %X", got, want) + } +} + +func TestDerEncodeLongFormLength(t *testing.T) { + // 200 bytes forces an 0x81 long-form length. + got := derEncode(0x30, make([]byte, 200)) + if got[0] != 0x30 || got[1] != 0x81 || got[2] != 200 { + t.Fatalf("derEncode 200-byte length header = %X", got[:3]) + } + // 1000 bytes forces an 0x82 long-form length. + got = derEncode(0x30, make([]byte, 1000)) + if got[0] != 0x30 || got[1] != 0x82 || got[2] != 0x03 || got[3] != 0xE8 { + t.Fatalf("derEncode 1000-byte length header = %X", got[:4]) + } +} + +// The encoder must round-trip through the existing decoder (derParse). +func TestDerRoundTrip(t *testing.T) { + inner := derConstruct(0xA0, + derEncode(0x80, []byte{0x11, 0x22}), + derEncode(0x5A, []byte{0x98, 0x10}), + ) + outer := derConstruct(0xBF38, derEncode(0x30, inner), derEncode(0x04, []byte{0xFF})) + + nodes := derParse(outer) + if len(nodes) != 1 || nodes[0].tag != 0xBF38 { + t.Fatalf("expected single BF38 root, got %#v", nodes) + } + seq := derValue(nodes[0].children, 0x30) + if seq == nil { + t.Fatalf("missing 0x30 child") + } + if got := derFindValue(seq, 0x80); !bytes.Equal(got, []byte{0x11, 0x22}) { + t.Fatalf("nested 0x80 = %X", got) + } + if got := derValue(nodes[0].children, 0x04); !bytes.Equal(got, []byte{0xFF}) { + t.Fatalf("0x04 = %X", got) + } +} + +func TestDerElementAt(t *testing.T) { + // BF38 { 30 03 010203 , 04 02 AABB } + buf := derConstruct(0xBF38, derEncode(0x30, []byte{1, 2, 3}), derEncode(0x04, []byte{0xAA, 0xBB})) + tag, headerLen, totalLen, err := derElementAt(buf, 0) + if err != nil { + t.Fatalf("derElementAt: %v", err) + } + if tag != 0xBF38 || headerLen != 3 || totalLen != len(buf) { + t.Fatalf("root: tag=%X header=%d total=%d (len=%d)", tag, headerLen, totalLen, len(buf)) + } + // First child starts right after the root header. + tag, headerLen, totalLen, err = derElementAt(buf, 3) + if err != nil || tag != 0x30 || headerLen != 2 || totalLen != 5 { + t.Fatalf("child: tag=%X header=%d total=%d err=%v", tag, headerLen, totalLen, err) + } +} + +func TestUnwrapDER(t *testing.T) { + // Already wrapped in 5F37 → returns inner value. + wrapped := derEncode(0x5F37, []byte{0x01, 0x02}) + if got := unwrapDER(wrapped, 0x5F37); !bytes.Equal(got, []byte{0x01, 0x02}) { + t.Fatalf("unwrapDER wrapped = %X", got) + } + // Bare value → returned unchanged. + bare := []byte{0x09, 0x08} + if got := unwrapDER(bare, 0x5F37); !bytes.Equal(got, bare) { + t.Fatalf("unwrapDER bare = %X", got) + } +} diff --git a/internal/device/esim_disable.go b/internal/device/esim_disable.go new file mode 100644 index 0000000..6ae2723 --- /dev/null +++ b/internal/device/esim_disable.go @@ -0,0 +1,98 @@ +package device + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +var ( + ErrESIMDisableProfileNotFound = errors.New("esim: profile to disable was not found on the eUICC") + ErrESIMProfileNotEnabled = errors.New("esim: profile is not currently enabled") + ErrESIMDisableDisallowedByPolicy = errors.New("esim: profile disabling is not allowed by its policy") + ErrESIMDisableCATBusy = errors.New("esim: card application toolkit is busy; retry disabling later") +) + +func buildDisableProfileRequest(iccid string) ([]byte, error) { + bcd, err := encodeICCID(strings.TrimSpace(iccid)) + if err != nil { + return nil, err + } + // SGP.22 ES10c DisableProfileRequest: + // BF32 { A0 { 5A } 81 01 FF } (refreshFlag = true). + profileID := derConstruct(0xA0, derEncode(0x5A, bcd)) + return derConstruct(0xBF32, profileID, derEncode(0x81, []byte{0xFF})), nil +} + +func disableProfileResult(payload []byte) (byte, bool) { + nodes := derParse(payload) + if len(nodes) != 1 || nodes[0].tag != 0xBF32 { + return 0, false + } + result := derFindValue(payload, 0x80) + if len(result) != 1 { + return 0, false + } + return result[0], true +} + +func disableProfileResponseError(result byte, payload []byte) error { + raw := strings.ToUpper(hex.EncodeToString(payload)) + switch result { + case 0: + return nil + case 1: + return fmt.Errorf("%w (result=0x%02X, raw %s)", ErrESIMDisableProfileNotFound, result, raw) + case 2: + return fmt.Errorf("%w (result=0x%02X, raw %s)", ErrESIMProfileNotEnabled, result, raw) + case 3: + return fmt.Errorf("%w (result=0x%02X, raw %s)", ErrESIMDisableDisallowedByPolicy, result, raw) + case 5: + return fmt.Errorf("%w (result=0x%02X, raw %s)", ErrESIMDisableCATBusy, result, raw) + default: + return fmt.Errorf("esim: eUICC rejected DisableProfile, result=0x%02X (raw %s)", result, raw) + } +} + +// ESIMDisableProfile disables the currently enabled profile through ES10c. +// With refreshFlag=true, the modem must be reset/re-discovered after commit. +func (manager *Manager) ESIMDisableProfile(ctx context.Context, id, iccid, aidHex string) error { + request, err := buildDisableProfileRequest(iccid) + if err != nil { + return err + } + manager.esimMu.Lock() + defer manager.esimMu.Unlock() + if err := manager.waitForESIMRecovery(ctx, id); err != nil { + return err + } + channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex)) + if err != nil { + return err + } + + commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout) + payload, err := channel.es10(commitContext, request) + cancelCommit() + closeContext, cancelClose := context.WithTimeout(context.Background(), csimAPDUTimeout) + channel.close(closeContext) + cancelClose() + if err != nil { + // The card may have committed immediately before a transport failure. + manager.startProfileSwitchRecovery(id) + return err + } + result, ok := disableProfileResult(payload) + if !ok { + manager.startProfileSwitchRecovery(id) + return fmt.Errorf("esim: unexpected DisableProfile response %s", strings.ToUpper(hex.EncodeToString(payload))) + } + if err := disableProfileResponseError(result, payload); err != nil { + return err + } + manager.markCachedProfileDisabled(id, strings.TrimSpace(iccid)) + manager.startProfileSwitchRecovery(id) + return nil +} diff --git a/internal/device/esim_download.go b/internal/device/esim_download.go new file mode 100644 index 0000000..2f97e97 --- /dev/null +++ b/internal/device/esim_download.go @@ -0,0 +1,303 @@ +package device + +import ( + "context" + "errors" + "strings" +) + +// EsimDownloadParams are the SPA download form fields, mapped from the +// snake_case query params by the HTTP layer. +type EsimDownloadParams struct { + SMDP string + MatchingID string + ConfirmationCode string + AIDHex string + IMEI string +} + +// EsimProgress is one download step emitted to the SSE stream. +type EsimProgress struct { + Step string + Msg string + Pct int +} + +// EsimDownloadResult reports a completed install. +type EsimDownloadResult struct { + ICCID string + SpaceDelta int64 // bytes consumed (positive) + Warning string +} + +// ESIMDownloadProfile downloads and installs one eSIM profile (SGP.22 §3): +// challenge/info → ES9+ InitiateAuthentication → ES10b AuthenticateServer → +// ES9+ AuthenticateClient → ES10b PrepareDownload → ES9+ GetBoundProfilePackage +// → ES10b LoadBoundProfilePackage → ES9+ HandleNotification. progress is invoked +// with the SPA's expected step/pct sequence. The whole run holds the device's +// eSIM lock so a concurrent list/switch cannot disturb the card mid-install. +func (manager *Manager) ESIMDownloadProfile(ctx context.Context, id string, params EsimDownloadParams, progress func(EsimProgress)) (*EsimDownloadResult, error) { + smdp := strings.TrimSpace(params.SMDP) + if smdp == "" { + return nil, errors.New("esim: SM-DP+ 地址不能为空") + } + report := func(step, msg string, pct int) { + if progress != nil { + progress(EsimProgress{Step: step, Msg: msg, Pct: pct}) + } + } + + manager.esimMu.Lock() + defer manager.esimMu.Unlock() + + report("preflight", "正在检查 eUICC 剩余空间...", 10) + channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(params.AIDHex)) + if err != nil { + return nil, err + } + defer channel.close(context.Background()) + + // Free NVRAM before/after drives both the preflight check and space_delta. + freeBefore := 0 + if info2, err := channel.getEUICCInfo2(ctx); err == nil { + if n, ok := euiccFreeNVRAM(info2); ok { + freeBefore = n + } + } + + challenge, err := channel.getEUICCChallenge(ctx) + if err != nil { + return nil, err + } + info1, err := channel.getEUICCInfo1(ctx) + if err != nil { + return nil, err + } + + client := newES9PClient(smdp) + + report("auth_client", "正在向 SM-DP+ 进行客户端身份认证...", 30) + init, err := client.initiateAuthentication(ctx, challenge, info1) + if err != nil { + return nil, err + } + transactionID := init.TransactionID + transactionIDBytes := derFindValue(init.ServerSigned1, 0x80) + + // Best-effort session cleanup if anything fails after the transaction opens + // (card-side CancelSession BF41, then server-side ES9+ cancelSession). + finished := false + defer func() { + if !finished && len(transactionIDBytes) > 0 { + if cancelResp, cerr := channel.cancelSession(context.Background(), transactionIDBytes, 0x00); cerr == nil { + _ = client.cancelSession(context.Background(), transactionID, cancelResp) + } + } + }() + + authResponse, err := channel.authenticateServer(ctx, init, params.MatchingID, params.IMEI) + if err != nil { + return nil, err + } + + // A "cert not trusted"/"matchingID refused"/"EID mismatch" failure surfaces + // here, from the SM-DP+'s functionExecutionStatus. + auth, err := client.authenticateClient(ctx, transactionID, authResponse) + if err != nil { + return nil, err + } + + report("download", "正在获取 Profile 数据包...", 55) + prepareResponse, err := channel.prepareDownload(ctx, auth, params.ConfirmationCode) + if err != nil { + return nil, err + } + + bpp, err := client.getBoundProfilePackage(ctx, transactionID, prepareResponse) + if err != nil { + return nil, err + } + + report("install", "正在将 Profile 写入 eUICC...", 80) + installResponse, err := channel.loadBoundProfilePackage(ctx, bpp, func(done, total int) { + if total > 0 { + report("install", "正在将 Profile 写入 eUICC...", 80+done*8/total) + } + }) + if err != nil { + return nil, err + } + iccid, err := installationResult(installResponse) + if err != nil { + return nil, err + } + + report("notify", "正在向运营商发送下载通知...", 90) + warning := "" + if err := client.handleNotification(ctx, installResponse); err != nil { + warning = "Profile 已安装,但下载通知发送失败" + } + + freeAfter := freeBefore + if info2, err := channel.getEUICCInfo2(ctx); err == nil { + if n, ok := euiccFreeNVRAM(info2); ok { + freeAfter = n + } + } + spaceDelta := freeBefore - freeAfter + if spaceDelta <= 0 { + spaceDelta = len(bpp) // fall back to the package size when NVRAM unreadable + } + + // The HTTP layer owns the final "done" event (it attaches space_delta/warning). + finished = true + return &EsimDownloadResult{ICCID: iccid, SpaceDelta: int64(spaceDelta), Warning: warning}, nil +} + +// ESIMDownloadErrorCode maps a download failure to a stable SPA error code. +// Keep the matching deliberately tolerant because some SM-DP+ implementations +// return only a free-form statusCodeData.message. +func ESIMDownloadErrorCode(err error) string { + var authenticateErr *esimAuthenticateError + if errors.As(err, &authenticateErr) { + return "euicc_authentication_failed" + } + var es9pErr *es9pError + if errors.As(err, &es9pErr) { + switch { + case es9pErr.SubjectCode == "8.1" && es9pErr.ReasonCode == "4.8": + return "euicc_insufficient_memory" + case es9pErr.SubjectCode == "8.8.4" && es9pErr.ReasonCode == "3.7": + return "euicc_ci_incompatible" + case es9pErr.SubjectCode == "8.2.6" && es9pErr.ReasonCode == "3.8": + return "activation_code_refused" + case es9pErr.SubjectCode == "8.2.5" && es9pErr.ReasonCode == "3.7": + return "profile_pool_empty" + } + } + var installErr *esimInstallError + if errors.As(err, &installErr) && installErr.ErrorReason == 10 { + return "euicc_insufficient_memory" + } + lower := strings.ToLower(err.Error()) + if strings.Contains(lower, "insufficient") || strings.Contains(lower, "空间不足") { + return "euicc_insufficient_memory" + } + if strings.Contains(lower, "cert.dpauth") && + (strings.Contains(lower, "root ca") || strings.Contains(lower, "public key supported by the euicc")) { + return "euicc_ci_incompatible" + } + if strings.Contains(lower, "campaign resource pool is empty") || + strings.Contains(lower, "no more profile available") { + return "profile_pool_empty" + } + if strings.Contains(lower, "matchingid") && strings.Contains(lower, "refused") || lower == "refused" { + return "activation_code_refused" + } + return "download_failed" +} + +// EsimChipInfo describes the eUICC for the SPA's eSIM chip header. +type EsimChipInfo struct { + EID string + AID string + FreeNvramBytes int + HasFreeNvram bool + TrustedCIs []string // raw hex SubjectKeyIdentifiers + Certificates []string // friendly CI names (证书) + FirmwareVer string // euiccFirmwareVer (固件) + Manufacturer string // EUM issuer → 生产商 + DefaultSmdpAddress string // ES10a default SM-DP+ + RootDsAddress string // ES10a Root SM-DS + SAS string // sasAccreditationNumber +} + +// ESIMChipInfo reads the eUICC's EID, EUICCInfo2, and configured addresses for +// the chip header. It takes the eSIM lock like the other card ops. +func (manager *Manager) ESIMChipInfo(ctx context.Context, id string) (*EsimChipInfo, error) { + manager.esimMu.Lock() + defer manager.esimMu.Unlock() + channel, err := manager.openEuicc(ctx, id) + if err != nil { + return nil, err + } + defer channel.close(context.Background()) + + info, err := readEsimChipInfo(ctx, channel, isdRAID) + if err != nil { + return nil, err + } + return &info, nil +} + +func readEsimChipInfo(ctx context.Context, channel *euiccChannel, aidHex string) (EsimChipInfo, error) { + info := EsimChipInfo{AID: aidHex} + if eid, err := channel.getEID(ctx); err == nil { + info.EID = eid + info.Manufacturer = eumManufacturerForEID(eid) + } + if info2, err := channel.getEUICCInfo2(ctx); err == nil { + if n, ok := euiccFreeNVRAM(info2); ok { + info.FreeNvramBytes = n + info.HasFreeNvram = true + } + info.TrustedCIs = euiccTrustedCIs(info2) + info.FirmwareVer = euiccFirmwareVersion(info2) + info.SAS = euiccSAS(info2) + for _, hexID := range info.TrustedCIs { + info.Certificates = append(info.Certificates, ciKeyFriendlyName(hexID)) + } + } + if def, root := channel.getEuiccConfiguredAddresses(ctx); def != "" || root != "" { + info.DefaultSmdpAddress = def + info.RootDsAddress = root + } + // Report whatever we read (even partial); only a channel-open failure above + // is fatal. A wholly-empty result means the eUICC exposed nothing usable. + if info.EID == "" && !info.HasFreeNvram && len(info.TrustedCIs) == 0 { + return EsimChipInfo{}, errors.New("esim: eUICC did not report chip info") + } + return info, nil +} + +// ESIMInventory reads every independently addressable eUICC storage exposed by +// the inserted card. It is entirely read-only: only SELECT, GetProfilesInfo, +// GetEuiccData, GetEuiccInfo2 and GetEuiccConfiguredAddresses are issued. +func (manager *Manager) ESIMInventory(ctx context.Context, id string) ([]EsimInventoryEntry, error) { + manager.esimMu.Lock() + defer manager.esimMu.Unlock() + if manager.esimRecoveryActive(id) { + return nil, errESIMRecovering + } + + aids := manager.discoverEuiccAIDs(ctx, id) + entries := make([]EsimInventoryEntry, 0, len(aids)) + var lastErr error + for _, aid := range aids { + channel, err := manager.openEuiccAID(ctx, id, aid) + if err != nil { + lastErr = err + continue + } + profilePayload, profileErr := channel.es10(ctx, []byte{0xBF, 0x2D, 0x00}) + chip, chipErr := readEsimChipInfo(ctx, channel, aid) + channel.close(context.Background()) + if profileErr != nil { + lastErr = profileErr + continue + } + if chipErr != nil { + lastErr = chipErr + continue + } + info := EsimInfo{EID: chip.EID, AID: aid, Profiles: parseProfilesInfo(profilePayload)} + entries = append(entries, EsimInventoryEntry{Info: info, Chip: chip}) + } + if len(entries) == 0 { + if lastErr != nil { + return nil, lastErr + } + return nil, ErrNoEUICC + } + return entries, nil +} diff --git a/internal/device/esim_lpa.go b/internal/device/esim_lpa.go new file mode 100644 index 0000000..f72bdea --- /dev/null +++ b/internal/device/esim_lpa.go @@ -0,0 +1,599 @@ +package device + +import ( + "context" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "strings" +) + +// eSIM profile download (SGP.22 §3, "写卡"). This orchestrates the LPA download +// flow over the modem's AT+CSIM eUICC channel (ES10b/ES10c) plus an ES9+ HTTPS +// client (es9p.go). The host performs no credential cryptography — the eUICC +// verifies the SM-DP+ certificate against its embedded CI root and unwraps the +// SCP03t-protected package on-card; the host only relays DER blobs between the +// SM-DP+ and the card. +// +// The wire formats here were verified byte-for-byte against lpac +// (euicc/es10b.c, es10c.c, es9p.c) and exercised against a live eUICC. + +// es10SegmentMSS caps each STORE DATA block. 120 matches lpac's es10x_mss and +// keeps every AT+CSIM command small enough for both the modem's buffer and the +// session's 512-byte command limit (120 APDU bytes ≈ 270 AT chars). +const es10SegmentMSS = 120 + +// storeDataChained sends one ES10 request body as one or more chained STORE +// DATA blocks (CLA=80, INS=E2). Blocks use P1=0x11 while more follow and 0x91 on +// the last, with a per-command block counter in P2 — exactly lpac's +// es10x_command_iter. The eUICC's streamed responses (drained via 61xx in +// transmit) are concatenated and returned. +func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []byte) ([]byte, error) { + var assembled []byte + sequence := byte(0) + for offset := 0; offset < len(derRequest); { + size := len(derRequest) - offset + last := true + if size > es10SegmentMSS { + size = es10SegmentMSS + last = false + } + p1 := byte(0x91) + if !last { + p1 = 0x11 + } + // A block is at most es10SegmentMSS bytes, so short-form Lc always fits. + apdu := []byte{0x80, 0xE2, p1, sequence, byte(size)} + apdu = append(apdu, derRequest[offset:offset+size]...) + apdu = append(apdu, 0x00) // Le + payload, sw, err := channel.transmit(ctx, apdu, 0x80) + if err != nil { + return nil, err + } + if sw != 0x9000 { + return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw) + } + assembled = append(assembled, payload...) + offset += size + sequence++ + } + return assembled, nil +} + +// getEUICCChallenge (ES10c, BF2E) returns the eUICC challenge bytes. +func (channel *euiccChannel) getEUICCChallenge(ctx context.Context) ([]byte, error) { + payload, err := channel.es10(ctx, []byte{0xBF, 0x2E, 0x00}) + if err != nil { + return nil, err + } + challenge := derFindValue(payload, 0x80) + if len(challenge) == 0 { + return nil, errors.New("esim: eUICC returned no challenge") + } + return challenge, nil +} + +// getEUICCInfo1 (ES10c, BF20) returns the raw EuiccInfo1 TLV (tag included) — +// this is exactly the base64'd euiccInfo1 that ES9+ InitiateAuthentication wants. +func (channel *euiccChannel) getEUICCInfo1(ctx context.Context) ([]byte, error) { + return channel.es10(ctx, []byte{0xBF, 0x20, 0x00}) +} + +// getEUICCInfo2 (ES10c, BF22) returns the raw EuiccInfo2 TLV (tag included), +// used for the chip header (EID, free NVRAM, trusted CI list). +func (channel *euiccChannel) getEUICCInfo2(ctx context.Context) ([]byte, error) { + return channel.es10(ctx, []byte{0xBF, 0x22, 0x00}) +} + +// getEuiccConfiguredAddresses (ES10a, BF3C) returns the default SM-DP+ address +// (tag 0x80) and the Root SM-DS address (tag 0x81). These live in their own +// command, separate from EUICCInfo2. +func (channel *euiccChannel) getEuiccConfiguredAddresses(ctx context.Context) (defaultSmdp, rootDs string) { + payload, err := channel.es10(ctx, []byte{0xBF, 0x3C, 0x00}) + if err != nil { + return "", "" + } + if root := derFindAll(derParse(payload), 0xBF3C); len(root) > 0 { + children := derParse(root[0].value) + if v := derValue(children, 0x80); len(v) > 0 { + defaultSmdp = string(v) + } + if v := derValue(children, 0x81); len(v) > 0 { + rootDs = string(v) + } + } + return defaultSmdp, rootDs +} + +// euiccFirmwareVersion extracts euiccFirmwareVer (BF22 → 0x83) and renders it as +// a dotted version. EUICCInfo2 stores the firmware version as three binary bytes +// (major.minor.patch), NOT ASCII — this matches lpac's _versiontype2str +// ("%d.%d.%d"), so a card returning 0x19 0x04 0x00 renders as "25.4.0". +func euiccFirmwareVersion(euiccInfo2 []byte) string { + v := derFindValue(euiccInfo2, 0x83) + if len(v) != 3 { + return "" + } + return fmt.Sprintf("%d.%d.%d", v[0], v[1], v[2]) +} + +// euiccSAS extracts sasAccreditationNumber (BF22 → 0x0C) as a string. +func euiccSAS(euiccInfo2 []byte) string { + if v := derFindValue(euiccInfo2, 0x0C); len(v) > 0 { + return strings.TrimSpace(string(v)) + } + return "" +} + +// getEID (ES10c GetEuiccData, BF3E requesting tag 5A) returns the eUICC's EID +// as 32 uppercase hex digits. +func (channel *euiccChannel) getEID(ctx context.Context) (string, error) { + request := derConstruct(0xBF3E, derEncode(0x5C, []byte{0x5A})) + payload, err := channel.es10(ctx, request) + if err != nil { + return "", err + } + eid := derFindValue(payload, 0x5A) + if len(eid) == 0 { + return "", errors.New("esim: eUICC returned no EID") + } + return strings.ToUpper(hex.EncodeToString(eid)), nil +} + +// euiccTrustedCIs extracts the euiccCiPKIdListForVerification (BF22 → 0xA9) as +// uppercase hex key identifiers — the root CIs this eUICC will verify against. +func euiccTrustedCIs(euiccInfo2 []byte) []string { + var out []string + for _, list := range derFindAll(derParse(euiccInfo2), 0xA9) { + for _, node := range derParse(list.value) { + if len(node.value) > 0 { + out = append(out, strings.ToUpper(hex.EncodeToString(node.value))) + } + } + } + return out +} + +// ciKeyNameTable maps the SubjectKeyIdentifier (SHA-1 of the CI public key) of +// each GSMA-published RSP root CI to the friendly name the eSIM ecosystem uses +// (the same labels VoHive shows under 证书). Only the production and test roots +// the card population actually carries are listed; an unknown ID renders as its +// hex so the field is never silently empty. +var ciKeyNameTable = map[string]string{ + "81370F5125D0B1D408D4C3B232E6D25E795BEBFB": "GSM Association - RSP2 Root CI1", + "4DE04679565824D8B0F9A8DE54A24E0EC20D6E2D": "GSM Association - RSP2 Root CI2", + "2C0F9A60BC975B2D8CDBF1273F6DEB07BF2695AF": "GSM Association - RSP2 Root CI3", + "84660C5F8824FA8023D730ECB1F5F33A2EA78A6B": "GSM Association - RSP2 Root CI3 (EUMet)", + "DBF1DFA0D9B6AB4D6F5D9D1F4D7B6F5D9D1F4D7B": "GSM Association - TEST Root CI1", + "1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F": "GSM Association - TEST Root CI2", +} + +// ciKeyFriendlyName renders one hex CI key ID as the friendly CI name, falling +// back to the raw hex when no entry is known. +func ciKeyFriendlyName(hexID string) string { + hexID = strings.ToUpper(hexID) + if name, ok := ciKeyNameTable[hexID]; ok { + return name + } + return hexID +} + +// eumManufacturerForEID derives the eUICC manufacturer from the EID's EUM +// issuer identifier. Per GSMA SGP.02, the EID is 32 BCD digits: nibble 0 is the +// EID version and nibbles 1-4 (eid[1:5]) carry the four-digit EUM issuer code — +// the same code VoHive surfaces as 生产商. +var eumManufacturerTable = map[string]string{ + "5840": "WatchData Technologies Ltd.", + "4990": "G+D Mobile Security GmbH", + "3590": "Thales DIS France SAS", + "3592": "Thales DIS France SAS", + "4901": "Idemia France SAS", + "4040": "Gemalto AG", + "8901": "Hutopt Technology (Shanghai) Co., Ltd.", + // Observed on firmware 4.2.0 together with SAS-UP certificate + // ED-ZI-UP-0826, which GSMA issued to Eastcompeace's Zhuhai site. + "9086": "Eastcompeace Technology Co., Ltd.", +} + +// Some newer EIDs use the eight-digit issuer prefix published in the GSMA EUM +// registry rather than matching the older four-digit extraction above. +var eidManufacturerPrefixTable = map[string]string{ + "89033023": "Thales DIS France SAS", +} + +// eumManufacturerForEID returns the manufacturer name for the EID's EUM code, or +// "" when the issuer is not in the table. +func eumManufacturerForEID(eid string) string { + eid = strings.ToUpper(strings.TrimSpace(eid)) + if len(eid) >= 8 { + if manufacturer, ok := eidManufacturerPrefixTable[eid[:8]]; ok { + return manufacturer + } + } + if len(eid) < 5 { + return "" + } + return eumManufacturerTable[eid[1:5]] +} + +// cancelSession (ES10b, BF41) aborts an open download transaction on-card and +// returns the CancelSessionResponse to relay to ES9+ cancelSession. reason 0x00 +// is endUserRejection (the generic abort). Best-effort cleanup only. +func (channel *euiccChannel) cancelSession(ctx context.Context, transactionID []byte, reason byte) ([]byte, error) { + request := derConstruct(0xBF41, + derEncode(0x80, transactionID), + derEncode(0x81, []byte{reason}), + ) + return channel.es10(ctx, request) +} + +// euiccFreeNVRAM extracts extCardResource.freeNonVolatileMemory (BF22 → 0x84 → +// 0x82) from a EuiccInfo2 TLV. extCardResource (0x84) is BER primitive-encoded, +// so its children are read from its raw value, not .children. ok is false when +// the field is absent. +func euiccFreeNVRAM(euiccInfo2 []byte) (int, bool) { + for _, res := range derFindAll(derParse(euiccInfo2), 0x84) { + if value := derFindValue(res.value, 0x82); len(value) > 0 { + n := 0 + for _, b := range value { + n = n<<8 | int(b) + } + return n, true + } + } + return 0, false +} + +// authenticateServer (ES10b, BF38) presents the SM-DP+'s credentials to the +// eUICC, which verifies the certificate chain against its embedded CI root. The +// whole card response is the AuthenticateServerResponse relayed to ES9+ +// AuthenticateClient. matchingId/imei are optional ctxParams1 inputs. +func (channel *euiccChannel) authenticateServer(ctx context.Context, init *es9pInitiateResult, matchingID, imei string) ([]byte, error) { + // deviceInfo (A1): tac (80, 4 BCD bytes), deviceCapabilities (A1, empty), + // optional imei (82, BCD). With no IMEI, lpac uses a fixed default TAC. + tac := []byte{0x35, 0x29, 0x06, 0x11} + var imeiField []byte + if digits := onlyDigits(imei); len(digits) >= 8 { + if bcd, err := encodeFixedDigitBCD(digits, 8, "IMEI"); err == nil { + tac = bcd[:4] + imeiField = derEncode(0x82, bcd) + } + } + deviceInfo := derConstruct(0xA1, derEncode(0x80, tac), derEncode(0xA1, nil)) + if imeiField != nil { + deviceInfo = derConstruct(0xA1, derEncode(0x80, tac), derEncode(0xA1, nil), imeiField) + } + + // ctxParams1 (A0): optional matchingId (80) then deviceInfo. + var ctxChildren [][]byte + if matchingID != "" { + ctxChildren = append(ctxChildren, derEncode(0x80, []byte(matchingID))) + } + ctxChildren = append(ctxChildren, deviceInfo) + ctxParams1 := derConstruct(0xA0, ctxChildren...) + + // The four server blobs arrive as complete TLVs (30/5F37/04/30). unwrapDER + + // re-encode normalizes them whether they come wrapped or bare, so the request + // is always well-formed. + request := derConstruct(0xBF38, + derEncode(0x30, unwrapDER(init.ServerSigned1, 0x30)), + derEncode(0x5F37, unwrapDER(init.ServerSignature1, 0x5F37)), + derEncode(0x04, unwrapDER(init.EuiccCiPKIDToBeUsed, 0x04)), + derEncode(0x30, unwrapDER(init.ServerCertificate, 0x30)), + ctxParams1, + ) + response, err := channel.es10(ctx, request) + if err != nil { + return nil, err + } + if err := authenticateServerResultError(response); err != nil { + return nil, fmt.Errorf("%w; selected CI=%X; %s", err, + unwrapDER(init.EuiccCiPKIDToBeUsed, 0x04), describeDPAuthCertificate(init.ServerCertificate)) + } + return response, nil +} + +func describeDPAuthCertificate(blob []byte) string { + certificate, err := x509.ParseCertificate(blob) + if err != nil { + return fmt.Sprintf("CERT.DPauth could not be parsed as X.509: %v", err) + } + return fmt.Sprintf( + "CERT.DPauth subject=%q issuer=%q valid=%s..%s SKI=%X AKI=%X signature=%s", + certificate.Subject.String(), certificate.Issuer.String(), + certificate.NotBefore.UTC().Format("2006-01-02T15:04:05Z"), + certificate.NotAfter.UTC().Format("2006-01-02T15:04:05Z"), + certificate.SubjectKeyId, certificate.AuthorityKeyId, certificate.SignatureAlgorithm, + ) +} + +// esimAuthenticateError is the AuthenticateErrorCode returned by the eUICC in +// an ES10b AuthenticateServerResponse error choice (BF38/A1). Keeping the card's +// code here prevents an SM-DP+ from collapsing every cause into the unhelpful +// "eUICC reported an authentication error" message. +type esimAuthenticateError struct { + Code int +} + +func (err *esimAuthenticateError) Error() string { + reasons := map[int]string{ + 1: "invalid server certificate", + 2: "invalid server signature", + 3: "unsupported elliptic curve", + 4: "no matching RSP session context", + 5: "invalid certificate OID", + 6: "eUICC challenge mismatch", + 7: "CI public key is unknown to the eUICC", + 8: "transaction ID error", + 9: "required certificate revocation list is missing", + 10: "invalid certificate revocation-list signature", + 11: "server certificate has been revoked", + 12: "invalid certificate or revocation-list time", + 13: "invalid certificate or revocation-list configuration", + 14: "invalid ICCID", + 127: "undefined authentication error", + } + reason := reasons[err.Code] + if reason == "" { + reason = "unknown authentication error" + } + return fmt.Sprintf("eSIM: eUICC AuthenticateServer failed: %s (code %d)", reason, err.Code) +} + +func authenticateServerResultError(response []byte) error { + var outer *derNode + for _, node := range derParse(response) { + if node.tag == 0xBF38 { + outer = node + break + } + } + if outer == nil || len(outer.children) == 0 || outer.children[0].tag != 0xA1 { + return nil + } + codeBytes := derFindValue(outer.children[0].value, 0x02) + if len(codeBytes) == 0 { + return &esimAuthenticateError{Code: -1} + } + code := 0 + for _, value := range codeBytes { + code = code<<8 | int(value) + } + return &esimAuthenticateError{Code: code} +} + +// prepareDownload (ES10b, BF21) authorizes the download on-card, including the +// confirmation-code hash when the SM-DP+ flags it required. The whole card +// response is the PrepareDownloadResponse relayed to ES9+ GetBoundProfilePackage. +func (channel *euiccChannel) prepareDownload(ctx context.Context, auth *es9pAuthenticateResult, confirmationCode string) ([]byte, error) { + // transactionId (0x80) and ccRequiredFlag (0x01) live inside smdpSigned2 (a + // SEQUENCE), so read them recursively from the raw blob. + transactionID := derFindValue(auth.SmdpSigned2, 0x80) + ccRequired := false + if flag := derFindValue(auth.SmdpSigned2, 0x01); len(flag) > 0 { + for _, b := range flag { + if b != 0 { + ccRequired = true + } + } + } + + var hashField []byte + if ccRequired { + if confirmationCode == "" { + return nil, errors.New("esim: this profile requires a confirmation code") + } + // hashCc = SHA256( SHA256(cc) || transactionId ) + first := sha256.Sum256([]byte(confirmationCode)) + second := sha256.New() + second.Write(first[:]) + second.Write(transactionID) + hashField = derEncode(0x04, second.Sum(nil)) + } + + children := [][]byte{ + derEncode(0x30, unwrapDER(auth.SmdpSigned2, 0x30)), + derEncode(0x5F37, unwrapDER(auth.SmdpSignature2, 0x5F37)), + } + if hashField != nil { + children = append(children, hashField) + } + children = append(children, derEncode(0x30, unwrapDER(auth.SmdpCertificate, 0x30))) + return channel.es10(ctx, derConstruct(0xBF21, children...)) +} + +// unwrapDER returns the inner value of a single-element TLV when the blob is +// already wrapped in the expected tag, else the blob unchanged. SM-DP+ blobs +// sometimes arrive as bare values and sometimes as full TLVs; this normalizes so +// we never double-wrap. +func unwrapDER(blob []byte, tag int) []byte { + got, headerLen, totalLen, err := derElementAt(blob, 0) + if err == nil && got == tag && totalLen == len(blob) { + return blob[headerLen:] + } + return blob +} + +// loadBoundProfilePackage (ES10b) streams the BoundProfilePackage into the eUICC. +// The package is sliced at TLV boundaries the way lpac does — [BF36 header + +// BF23], A0 whole, A1/A3 header then each child, A2 whole — and each slice is +// sent as a chained STORE DATA. Only the final slice returns data: the +// ProfileInstallationResult (BF37). progress is invoked per slice. +func (channel *euiccChannel) loadBoundProfilePackage(ctx context.Context, bpp []byte, progress func(done, total int)) ([]byte, error) { + segments, err := segmentBoundProfilePackage(bpp) + if err != nil { + return nil, err + } + var lastResponse []byte + for index, segment := range segments { + response, err := channel.storeDataChained(ctx, segment) + if err != nil { + return nil, err + } + if len(response) > 0 { + lastResponse = response + } + if progress != nil { + progress(index+1, len(segments)) + } + } + if len(lastResponse) == 0 { + return nil, errors.New("esim: eUICC returned no installation result") + } + return lastResponse, nil +} + +// segmentBoundProfilePackage splits a BoundProfilePackage (the BF36 element) +// into the TLV-aligned slices lpac uses for LoadBoundProfilePackage. +func segmentBoundProfilePackage(bpp []byte) ([][]byte, error) { + // Locate the BF36 (BoundProfilePackage) element at the top level. + offset := 0 + bf36Start, bf36Header, bf36Total := -1, 0, 0 + for offset < len(bpp) { + tag, headerLen, totalLen, err := derElementAt(bpp, offset) + if err != nil { + return nil, err + } + if tag == 0xBF36 { + bf36Start, bf36Header, bf36Total = offset, headerLen, totalLen + break + } + offset += totalLen + } + if bf36Start < 0 { + return nil, errors.New("esim: BoundProfilePackage (BF36) not found") + } + valueStart := bf36Start + bf36Header + valueEnd := bf36Start + bf36Total + + var segments [][]byte + cursor := valueStart + // First slice: BF36 header through the end of the first child (BF23, + // initialiseSecureChannelRequest) so the secure channel is set up first. + _, _, firstTotal, err := derElementAt(bpp, cursor) + if err != nil { + return nil, err + } + segments = append(segments, bpp[bf36Start:cursor+firstTotal]) + cursor += firstTotal + + for cursor < valueEnd { + tag, headerLen, totalLen, err := derElementAt(bpp, cursor) + if err != nil { + return nil, err + } + switch tag { + case 0xA1, 0xA3: // sequenceOf88 / sequenceOf86: header, then each child + segments = append(segments, bpp[cursor:cursor+headerLen]) + child := cursor + headerLen + childEnd := cursor + totalLen + for child < childEnd { + _, _, childTotal, err := derElementAt(bpp, child) + if err != nil { + return nil, err + } + segments = append(segments, bpp[child:child+childTotal]) + child += childTotal + } + default: // A0 / A2 (and anything unexpected): send whole + segments = append(segments, bpp[cursor:cursor+totalLen]) + } + cursor += totalLen + } + return segments, nil +} + +// esimInstallError is a card-side ProfileInstallationResult ErrorResult: the +// package was received but the eUICC refused to install it. +type esimInstallError struct { + CommandID int + ErrorReason int +} + +func (e *esimInstallError) Error() string { + if reason, ok := es10bErrorReasons[e.ErrorReason]; ok { + return "esim: eUICC 拒绝安装 Profile:" + reason + } + return fmt.Sprintf("esim: eUICC 拒绝安装 Profile (reason %d, command %d)", e.ErrorReason, e.CommandID) +} + +// es10bErrorReasons maps the ProfileInstallationResult errorReason to text. +// Values mirror lpac's enum es10b_error_reason. +var es10bErrorReasons = map[int]string{ + 1: "输入值不正确", + 2: "签名无效", + 3: "transactionId 无效", + 4: "不支持的 CRT 值", + 5: "不支持的远程操作类型", + 6: "不支持的 Profile 类别", + 7: "SCP03t 结构错误", + 8: "SCP03t 安全错误", + 9: "该 Profile (ICCID) 已存在于 eUICC", + 10: "eUICC 剩余空间不足", + 11: "安装被中断", + 12: "Profile 元素处理错误", + 13: "数据不匹配", + 14: "测试 Profile 的 NAA 密钥无效", + 15: "Profile 策略规则 (PPR) 不允许", + 127: "未知错误", +} + +// installationResult decodes the ProfileInstallationResult (BF37 → BF27 → +// BF2F NotificationMetadata + A2 finalResult[A0 success | A1 error]). It returns +// the new profile's ICCID on success, or a typed *esimInstallError on ErrorResult. +func installationResult(payload []byte) (string, error) { + roots := derParse(payload) + result := derFindAll(roots, 0xBF37) + if len(result) == 0 { + return "", fmt.Errorf("esim: no ProfileInstallationResult (BF37) in %s", strings.ToUpper(hex.EncodeToString(payload))) + } + data := derFindAll(result[0].children, 0xBF27) + if len(data) == 0 { + return "", errors.New("esim: no ProfileInstallationResultData (BF27)") + } + iccid := "" + for _, node := range derFindAll(data[0].children, 0x5A) { + iccid = decodeICCID(node.value) + break + } + finalResult := firstChild(data[0].children, 0xA2) + if finalResult == nil { + return "", errors.New("esim: ProfileInstallationResultData missing finalResult (A2)") + } + if errNode := firstChild(finalResult.children, 0xA1); errNode != nil { + installErr := &esimInstallError{CommandID: -1, ErrorReason: -1} + if v := derValue(errNode.children, 0x80); len(v) > 0 { + installErr.CommandID = int(v[0]) + } + if v := derValue(errNode.children, 0x81); len(v) > 0 { + installErr.ErrorReason = int(v[0]) + } + return "", installErr + } + if firstChild(finalResult.children, 0xA0) == nil { + return "", errors.New("esim: unexpected ProfileInstallationResult finalResult") + } + return iccid, nil +} + +// firstChild returns the first direct child with the given tag, or nil. +func firstChild(nodes []*derNode, tag int) *derNode { + for _, node := range nodes { + if node.tag == tag { + return node + } + } + return nil +} + +func onlyDigits(value string) string { + var builder strings.Builder + for _, r := range value { + if r >= '0' && r <= '9' { + builder.WriteRune(r) + } + } + return builder.String() +} diff --git a/internal/device/esim_lpa_test.go b/internal/device/esim_lpa_test.go new file mode 100644 index 0000000..629b269 --- /dev/null +++ b/internal/device/esim_lpa_test.go @@ -0,0 +1,157 @@ +package device + +import ( + "bytes" + "errors" + "testing" +) + +// buildTestBPP assembles a synthetic BoundProfilePackage (BF36) with the +// initialiseSecureChannel + A0/A1/A3 sequences lpac's segmenter cares about. +func buildTestBPP(t *testing.T) (bpp []byte, parts map[string][]byte) { + t.Helper() + parts = map[string][]byte{} + parts["bf23"] = tlv([]byte{0xBF, 0x23}, bytes.Repeat([]byte{0x01}, 20)) + parts["a0"] = tlv([]byte{0xA0}, bytes.Repeat([]byte{0x02}, 8)) + parts["a1c1"] = tlv([]byte{0x88}, bytes.Repeat([]byte{0x03}, 5)) + parts["a1c2"] = tlv([]byte{0x88}, bytes.Repeat([]byte{0x04}, 6)) + parts["a1"] = tlv([]byte{0xA1}, parts["a1c1"], parts["a1c2"]) + parts["a3c1"] = tlv([]byte{0x86}, bytes.Repeat([]byte{0x05}, 7)) + parts["a3"] = tlv([]byte{0xA3}, parts["a3c1"]) + bpp = tlv([]byte{0xBF, 0x36}, parts["bf23"], parts["a0"], parts["a1"], parts["a3"]) + return bpp, parts +} + +func TestSegmentBoundProfilePackage(t *testing.T) { + bpp, parts := buildTestBPP(t) + segments, err := segmentBoundProfilePackage(bpp) + if err != nil { + t.Fatalf("segmentBoundProfilePackage: %v", err) + } + + // The slices must reassemble into the exact original package. + if got := bytes.Join(segments, nil); !bytes.Equal(got, bpp) { + t.Fatalf("segments do not reassemble to the original BPP") + } + + // Segment 0 = BF36 header + complete BF23 (secure channel first). + if !bytes.HasPrefix(segments[0], []byte{0xBF, 0x36}) { + t.Fatalf("segment 0 must start with BF36, got %X", segments[0][:3]) + } + if !bytes.Contains(segments[0], parts["bf23"]) { + t.Fatalf("segment 0 must contain the full BF23") + } + // A0 sent whole; A1/A3 split header then children. + if !bytes.Equal(segments[1], parts["a0"]) { + t.Fatalf("segment 1 should be the whole A0") + } + if !bytes.Equal(segments[2], parts["a1"][:2]) { + t.Fatalf("segment 2 should be the A1 header only, got %X", segments[2]) + } + if !bytes.Equal(segments[3], parts["a1c1"]) || !bytes.Equal(segments[4], parts["a1c2"]) { + t.Fatalf("A1 children not segmented correctly") + } +} + +func TestSegmentBoundProfilePackageNoBF36(t *testing.T) { + if _, err := segmentBoundProfilePackage(tlv([]byte{0xBF, 0x35}, []byte{0x00})); err == nil { + t.Fatalf("expected error when BF36 is absent") + } +} + +func buildInstallResult(t *testing.T, finalResult []byte) []byte { + t.Helper() + iccidBCD, err := encodeICCID("8944476500017228672") + if err != nil { + t.Fatalf("encodeICCID: %v", err) + } + notifMeta := tlv([]byte{0xBF, 0x2F}, tlv([]byte{0x80}, []byte{0x01}), tlv([]byte{0x5A}, iccidBCD)) + bf27 := tlv([]byte{0xBF, 0x27}, notifMeta, finalResult) + return tlv([]byte{0xBF, 0x37}, bf27) +} + +func TestInstallationResultSuccess(t *testing.T) { + payload := buildInstallResult(t, tlv([]byte{0xA2}, tlv([]byte{0xA0}))) + iccid, err := installationResult(payload) + if err != nil { + t.Fatalf("installationResult: %v", err) + } + if iccid != "8944476500017228672" { + t.Fatalf("iccid = %q", iccid) + } +} + +func TestInstallationResultInsufficientMemory(t *testing.T) { + // A2 { A1 { 80 bppCommandId=5, 81 errorReason=10 (insufficient memory) } } + errResult := tlv([]byte{0xA1}, tlv([]byte{0x80}, []byte{0x05}), tlv([]byte{0x81}, []byte{0x0A})) + payload := buildInstallResult(t, tlv([]byte{0xA2}, errResult)) + + _, err := installationResult(payload) + if err == nil { + t.Fatalf("expected an installation error") + } + var installErr *esimInstallError + if !errors.As(err, &installErr) { + t.Fatalf("expected *esimInstallError, got %T (%v)", err, err) + } + if installErr.ErrorReason != 10 || installErr.CommandID != 5 { + t.Fatalf("got reason=%d command=%d", installErr.ErrorReason, installErr.CommandID) + } + if code := ESIMDownloadErrorCode(err); code != "euicc_insufficient_memory" { + t.Fatalf("error code = %q, want euicc_insufficient_memory", code) + } +} + +func TestESIMDownloadErrorCodePublicProfileFailures(t *testing.T) { + tests := []struct { + err error + want string + }{ + { + err: errors.New("The SM-DP+ has no CERT.DPauth.SIG which chains to one of the eSIM CA Root CA Certificate with a Public Key supported by the eUICC"), + want: "euicc_ci_incompatible", + }, + {err: errors.New("Refused"), want: "activation_code_refused"}, + {err: errors.New("campaign resource pool is empty"), want: "profile_pool_empty"}, + } + for _, test := range tests { + if got := ESIMDownloadErrorCode(test.err); got != test.want { + t.Errorf("ESIMDownloadErrorCode(%q) = %q, want %q", test.err, got, test.want) + } + } +} + +func TestAuthenticateServerResultError(t *testing.T) { + success := derConstruct(0xBF38, derConstruct(0xA0, derEncode(0x30, nil))) + if err := authenticateServerResultError(success); err != nil { + t.Fatalf("success response returned %v", err) + } + errorResponse := derConstruct(0xBF38, derConstruct(0xA1, + derConstruct(0x30, derEncode(0x80, []byte{0x01, 0x02}), derEncode(0x02, []byte{0x02})), + )) + err := authenticateServerResultError(errorResponse) + var authenticateErr *esimAuthenticateError + if !errors.As(err, &authenticateErr) || authenticateErr.Code != 2 { + t.Fatalf("error = %T %v, want authenticate code 2", err, err) + } + if code := ESIMDownloadErrorCode(err); code != "euicc_authentication_failed" { + t.Fatalf("download error code = %q", code) + } +} + +func TestEuiccFreeNVRAM(t *testing.T) { + // BF22 { 84 { 81 installedApp, 82 freeNonVolatile=0x05E849, 83 freeVolatile } } + extRes := tlv([]byte{0x84}, + tlv([]byte{0x81}, []byte{0x00}), + tlv([]byte{0x82}, []byte{0x05, 0xE8, 0x49}), + tlv([]byte{0x83}, []byte{0x00}), + ) + info2 := tlv([]byte{0xBF, 0x22}, extRes) + n, ok := euiccFreeNVRAM(info2) + if !ok || n != 387145 { + t.Fatalf("freeNVRAM = %d, ok=%v (want 387145)", n, ok) + } + if _, ok := euiccFreeNVRAM(tlv([]byte{0xBF, 0x22})); ok { + t.Fatalf("expected ok=false when extCardResource absent") + } +} diff --git a/internal/device/esim_rename.go b/internal/device/esim_rename.go new file mode 100644 index 0000000..e2557d0 --- /dev/null +++ b/internal/device/esim_rename.go @@ -0,0 +1,82 @@ +package device + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +var ( + ErrESIMNicknameTooLong = errors.New("esim: profile nickname must not exceed 64 characters") + ErrESIMNicknameProfileNotFound = errors.New("esim: profile to rename was not found on the eUICC") +) + +func buildSetNicknameRequest(iccid, nickname string) ([]byte, error) { + bcd, err := encodeICCID(strings.TrimSpace(iccid)) + if err != nil { + return nil, err + } + if !utf8.ValidString(nickname) { + return nil, errors.New("esim: profile nickname is not valid UTF-8") + } + if utf8.RuneCountInString(nickname) > 64 { + return nil, ErrESIMNicknameTooLong + } + // SGP.22 ES10c SetNicknameRequest: + // BF29 { 5A 90 }. + return derConstruct(0xBF29, derEncode(0x5A, bcd), derEncode(0x90, []byte(nickname))), nil +} + +func setNicknameResult(payload []byte) (byte, bool) { + nodes := derParse(payload) + if len(nodes) != 1 || nodes[0].tag != 0xBF29 { + return 0, false + } + result := derFindValue(payload, 0x80) + if len(result) != 1 { + return 0, false + } + return result[0], true +} + +// ESIMRenameProfile updates the on-card Profile Nickname through ES10c. It +// does not enable, disable, download, or delete a profile. +func (manager *Manager) ESIMRenameProfile(ctx context.Context, id, iccid, nickname, aidHex string) error { + request, err := buildSetNicknameRequest(iccid, nickname) + if err != nil { + return err + } + manager.esimMu.Lock() + defer manager.esimMu.Unlock() + if err := manager.waitForESIMRecovery(ctx, id); err != nil { + return err + } + channel, err := manager.openEuiccAID(ctx, id, targetEuiccAID(aidHex)) + if err != nil { + return err + } + defer channel.close(context.Background()) + + commitContext, cancelCommit := context.WithTimeout(context.WithoutCancel(ctx), csimAPDUTimeout) + payload, err := channel.es10(commitContext, request) + cancelCommit() + if err != nil { + return err + } + result, ok := setNicknameResult(payload) + if !ok { + return fmt.Errorf("esim: unexpected SetNickname response %s", strings.ToUpper(hex.EncodeToString(payload))) + } + switch result { + case 0: + manager.renameCachedProfile(id, strings.TrimSpace(iccid), nickname) + return nil + case 1: + return fmt.Errorf("%w (result=0x%02X)", ErrESIMNicknameProfileNotFound, result) + default: + return fmt.Errorf("esim: eUICC rejected SetNickname, result=0x%02X (raw %s)", result, strings.ToUpper(hex.EncodeToString(payload))) + } +} diff --git a/internal/device/esim_test.go b/internal/device/esim_test.go new file mode 100644 index 0000000..febd201 --- /dev/null +++ b/internal/device/esim_test.go @@ -0,0 +1,321 @@ +package device + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "testing" + "time" + + "vocat/internal/modem" +) + +// tlv builds one BER-TLV element from a (possibly multi-byte) tag and a body +// assembled from the given parts. +func tlv(tag []byte, parts ...[]byte) []byte { + var body []byte + for _, part := range parts { + body = append(body, part...) + } + out := append([]byte(nil), tag...) + switch { + case len(body) < 0x80: + out = append(out, byte(len(body))) + case len(body) < 0x100: + out = append(out, 0x81, byte(len(body))) + default: + out = append(out, 0x82, byte(len(body)>>8), byte(len(body))) + } + return append(out, body...) +} + +func esimTestProfile(t *testing.T, iccidDigits, provider, name string, state byte) []byte { + t.Helper() + bcd, err := encodeICCID(iccidDigits) + if err != nil { + t.Fatalf("encodeICCID: %v", err) + } + aid, _ := hex.DecodeString("A0000005591010FFFFFFFF8900001000") + // Icon deliberately contains 0x5A and 0xE3 bytes to prove the parser never + // descends into primitive (non-constructed) leaves. + icon := []byte{0x89, 0x50, 0x4E, 0x47, 0x5A, 0xE3, 0x05, 0x9F, 0x70, 0x01} + return tlv([]byte{0xE3}, + tlv([]byte{0x5A}, bcd), + tlv([]byte{0x4F}, aid), + tlv([]byte{0x9F, 0x70}, []byte{state}), + tlv([]byte{0x91}, []byte(provider)), + tlv([]byte{0x92}, []byte(name)), + tlv([]byte{0x94}, icon), + ) +} + +func TestParseProfilesInfoRealShape(t *testing.T) { + // BF2D root (this card echoes the request tag) -> A0 list -> E3 records. + body := tlv([]byte{0xA0}, + esimTestProfile(t, "89441000400128014257", "Vodafone UK", "Vodafone UK eSIM", 0x00), + esimTestProfile(t, "89441000430011604140", "Vodafone UK", "Vodafone UK eSIM", 0x01), + esimTestProfile(t, "89852351225001058508", "Webbing", "WEBBING", 0x00), + ) + payload := tlv([]byte{0xBF, 0x2D}, body) + + profiles := parseProfilesInfo(payload) + if len(profiles) != 3 { + t.Fatalf("expected 3 profiles, got %d: %#v", len(profiles), profiles) + } + if profiles[0].ICCID != "89441000400128014257" || profiles[0].State != 0 { + t.Fatalf("profile[0] = %#v", profiles[0]) + } + if profiles[1].ICCID != "89441000430011604140" || profiles[1].State != 1 || profiles[1].StateText != "已启用" { + t.Fatalf("profile[1] = %#v", profiles[1]) + } + if profiles[2].ServiceProvider != "Webbing" || profiles[2].Name != "WEBBING" || profiles[2].State != 0 { + t.Fatalf("profile[2] = %#v", profiles[2]) + } + info := &EsimInfo{Profiles: profiles} + enabled := info.EnabledProfile() + if enabled == nil || enabled.Name != "Vodafone UK eSIM" { + t.Fatalf("enabled profile = %#v", enabled) + } +} + +func TestParseProfilesInfoSkipsNestedMetadataE3WithoutICCID(t *testing.T) { + real := esimTestProfile(t, "89441000400316048687", "Vodafone UK", "Vodafone UK eSIM", 0x01) + duplicate := esimTestProfile(t, "89441000400316048687", "Duplicate", "Duplicate", 0x00) + metadata := tlv([]byte{0xE3}, tlv([]byte{0x80}, []byte{0x01})) + empty := tlv([]byte{0xE3}) + payload := tlv([]byte{0xBF, 0x2D}, tlv([]byte{0xA0}, metadata, real, empty, duplicate)) + + profiles := parseProfilesInfo(payload) + if len(profiles) != 1 { + t.Fatalf("profiles = %#v, want one addressable profile", profiles) + } + if profiles[0].ICCID != "89441000400316048687" || profiles[0].Name != "Vodafone UK eSIM" { + t.Fatalf("profile = %#v", profiles[0]) + } +} + +func TestICCIDRoundTrip(t *testing.T) { + for _, digits := range []string{"89441000400128014257", "8985235122500105850", "1"} { + bcd, err := encodeICCID(digits) + if err != nil { + t.Fatalf("encodeICCID(%q): %v", digits, err) + } + if len(bcd) != 10 { + t.Fatalf("encodeICCID(%q) length = %d, want fixed 10 octets", digits, len(bcd)) + } + if got := decodeICCID(bcd); got != digits { + t.Fatalf("round trip %q -> %q", digits, got) + } + } + if _, err := encodeICCID("894410004001280142571"); err == nil { + t.Fatal("21-digit ICCID was accepted") + } +} + +func TestEnableProfileRequestPads18DigitICCIDToTenOctets(t *testing.T) { + request, err := buildEnableProfileRequest("894921007608519523") + if err != nil { + t.Fatal(err) + } + if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3111A00C5A0A989412006780155932FF8101FF" { + t.Fatalf("EnableProfile request = %s", got) + } +} + +func TestDeleteProfileRequestAndResult(t *testing.T) { + request, err := buildDeleteProfileRequest("89441000400128014257") + if err != nil { + t.Fatal(err) + } + if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF330C5A0A98440100041082102475" { + t.Fatalf("DeleteProfile request = %s", got) + } + result, ok := deleteProfileResult([]byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x00}) + if !ok || result != 0 { + t.Fatalf("DeleteProfile result = (%d, %v)", result, ok) + } + activeResponse := []byte{0xBF, 0x33, 0x03, 0x80, 0x01, 0x02} + if err := deleteProfileResponseError(2, activeResponse); !errors.Is(err, ErrESIMDeleteProfileNotDisabled) { + t.Fatalf("DeleteProfile result 2 error = %v", err) + } +} + +func TestSetNicknameRequestAndResult(t *testing.T) { + request, err := buildSetNicknameRequest("89441000400128014257", "Test") + if err != nil { + t.Fatal(err) + } + if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF29125A0A98440100041082102475900454657374" { + t.Fatalf("SetNickname request = %s", got) + } + result, ok := setNicknameResult([]byte{0xBF, 0x29, 0x03, 0x80, 0x01, 0x00}) + if !ok || result != 0 { + t.Fatalf("SetNickname result = (%d, %v)", result, ok) + } + if _, err := buildSetNicknameRequest("89441000400128014257", strings.Repeat("名", 65)); !errors.Is(err, ErrESIMNicknameTooLong) { + t.Fatalf("long nickname error = %v", err) + } +} + +func TestDisableProfileRequestAndResult(t *testing.T) { + request, err := buildDisableProfileRequest("89441000400128014257") + if err != nil { + t.Fatal(err) + } + if got := strings.ToUpper(hex.EncodeToString(request)); got != "BF3211A00C5A0A984401000410821024758101FF" { + t.Fatalf("DisableProfile request = %s", got) + } + result, ok := disableProfileResult([]byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x00}) + if !ok || result != 0 { + t.Fatalf("DisableProfile result = (%d, %v)", result, ok) + } + busyResponse := []byte{0xBF, 0x32, 0x03, 0x80, 0x01, 0x05} + if err := disableProfileResponseError(5, busyResponse); !errors.Is(err, ErrESIMDisableCATBusy) { + t.Fatalf("DisableProfile result 5 error = %v", err) + } +} + +func TestEnableProfileResultErrors(t *testing.T) { + undefinedResponse := []byte{0xBF, 0x31, 0x03, 0x80, 0x01, 0x7F} + result, ok := enableProfileResult(undefinedResponse) + if !ok || result != 0x7F { + t.Fatalf("EnableProfile result = (%d, %v)", result, ok) + } + if err := enableProfileResponseError(byte(result), undefinedResponse); !errors.Is(err, ErrESIMEnableUndefined) { + t.Fatalf("EnableProfile undefinedError = %v", err) + } + policyResponse := []byte{0xBF, 0x31, 0x03, 0x80, 0x01, 0x03} + if err := enableProfileResponseError(3, policyResponse); !errors.Is(err, ErrESIMEnableDisallowedPolicy) { + t.Fatalf("EnableProfile policy error = %v", err) + } +} + +func TestVerifySwitchedICCIDReadsLiveModem(t *testing.T) { + client := &transcriptClient{steps: []clientStep{{ + command: "AT+CCID", + response: okResponse("+CCID: 89492026266006792824F"), + }}} + manager, id := newStartedTestManager(t, client) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := manager.verifySwitchedICCID(ctx, id, "89492026266006792824"); err != nil { + t.Fatalf("verifySwitchedICCID: %v", err) + } + client.assertDone(t) +} + +func TestEUMManufacturerForWatchData(t *testing.T) { + if got := eumManufacturerForEID("35840574202500000125000001855764"); got != "WatchData Technologies Ltd." { + t.Fatalf("manufacturer = %q", got) + } +} + +func TestEUMManufacturerForEastcompeace(t *testing.T) { + if got := eumManufacturerForEID("89086030202200000025000015085962"); got != "Eastcompeace Technology Co., Ltd." { + t.Fatalf("manufacturer = %q", got) + } +} + +func TestEUMManufacturerForModernThalesPrefix(t *testing.T) { + if got := eumManufacturerForEID("89033023427100000000056707807049"); got != "Thales DIS France SAS" { + t.Fatalf("manufacturer = %q", got) + } +} + +func TestEuiccSASTrimsCardPadding(t *testing.T) { + payload := derConstruct(0xBF22, derEncode(0x0C, []byte(" SAS-UP-TEST "))) + if got := euiccSAS(payload); got != "SAS-UP-TEST" { + t.Fatalf("SAS = %q", got) + } +} + +func TestTransientEuiccCMEClassification(t *testing.T) { + err := fmt.Errorf("select ISD-R: %w", &modem.CommandError{ + Command: `AT+CSIM=42,"01A40400"`, + Final: "+CME ERROR: 0", + }) + if !isTransientEuiccCME(err) { + t.Fatal("wrapped +CME ERROR: 0 must be retryable") + } + if isTransientEuiccCME(&modem.CommandError{Final: "+CME ERROR: 13"}) { + t.Fatal("SIM failure must not be classified as a transient SELECT error") + } +} + +func TestEUICCChannelStuckWrapsTransientCME(t *testing.T) { + cause := &modem.CommandError{ + Command: `AT+CSIM=10,"0070000001"`, + Final: "+CME ERROR: 0", + } + err := fmt.Errorf("%w: %v", ErrEUICCChannelStuck, cause) + if !errors.Is(err, ErrEUICCChannelStuck) { + t.Fatal("wrapped hot-swap channel failure must retain its sentinel") + } +} + +func TestWaitForESIMRecovery(t *testing.T) { + done := make(chan struct{}) + manager := &Manager{esimRecoveries: map[string]chan struct{}{"dev": done}} + go func() { + time.Sleep(10 * time.Millisecond) + close(done) + }() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if err := manager.waitForESIMRecovery(ctx, "dev"); err != nil { + t.Fatalf("waitForESIMRecovery: %v", err) + } + + blocked := make(chan struct{}) + manager.esimRecoveries["blocked"] = blocked + timeoutContext, cancelTimeout := context.WithTimeout(context.Background(), time.Millisecond) + defer cancelTimeout() + if err := manager.waitForESIMRecovery(timeoutContext, "blocked"); err == nil { + t.Fatal("waitForESIMRecovery must honor caller cancellation") + } +} + +func TestESIMListProfilesReturnsCacheDuringRecovery(t *testing.T) { + done := make(chan struct{}) + manager := &Manager{ + esimRecoveries: map[string]chan struct{}{"dev": done}, + esimCache: map[string]EsimInfo{ + "dev": {Profiles: []EsimProfile{{ICCID: "old", State: 1}}}, + }, + } + + info, err := manager.ESIMListProfiles(context.Background(), "dev") + if err != nil { + t.Fatalf("ESIMListProfiles during recovery: %v", err) + } + if len(info.Profiles) != 1 || info.Profiles[0].ICCID != "old" { + t.Fatalf("cached profiles = %#v", info.Profiles) + } + + // The returned value must not alias the manager cache. + info.Profiles[0].ICCID = "changed" + cached, _ := manager.cachedESIMInfo("dev") + if cached.Profiles[0].ICCID != "old" { + t.Fatalf("caller mutated cache: %#v", cached.Profiles) + } +} + +func TestMarkCachedProfileEnabled(t *testing.T) { + manager := &Manager{esimCache: map[string]EsimInfo{ + "dev": {Profiles: []EsimProfile{ + {ICCID: "old", State: 1, StateText: "old state"}, + {ICCID: "target", State: 0, StateText: "target state"}, + }}, + }} + + manager.markCachedProfileEnabled("dev", "target") + info, ok := manager.cachedESIMInfo("dev") + if !ok || info.Profiles[0].State != 0 || info.Profiles[0].StateText != "已禁用" { + t.Fatalf("old profile state = %#v", info.Profiles[0]) + } + if info.Profiles[1].State != 1 || info.Profiles[1].StateText != "已启用" { + t.Fatalf("target profile state = %#v", info.Profiles[1]) + } +} diff --git a/internal/device/manager.go b/internal/device/manager.go new file mode 100644 index 0000000..438d64f --- /dev/null +++ b/internal/device/manager.go @@ -0,0 +1,531 @@ +package device + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "sync" + "time" + + "vocat/internal/modem" +) + +type Options struct { + Discoverer modem.Discoverer + Opener modem.Opener + CommandTimeout time.Duration + LongTimeout time.Duration + SMSTimeout time.Duration + ScanTimeout time.Duration +} + +type Manager struct { + mu sync.RWMutex + esimMu sync.Mutex // serializes eSIM card access (list/switch/download) + esimRecoveryMu sync.Mutex + esimRecoveries map[string]chan struct{} + esimCacheMu sync.RWMutex + esimCache map[string]EsimInfo + discoverer modem.Discoverer + opener modem.Opener + commandTimeout time.Duration + longTimeout time.Duration + smsTimeout time.Duration + scanTimeout time.Duration + started bool + devices map[string]*managedDevice + ussdSessions map[string]ussdSession +} + +// ussdSession tracks an open USSD dialog on a device so a follow-up Continue or +// Cancel can be routed back to the right modem. The modem owns the actual +// network session; this map only records which device a session id belongs to. +type ussdSession struct { + deviceID string + createdAt time.Time +} + +type managedDevice struct { + opMu sync.Mutex + candidate modem.Candidate + client modem.Client + snapshot *Snapshot + lastError string + lastUpdated time.Time + discovered bool + preFlightMode *int + resetClientOnLock bool +} + +func NewManager(options Options) (*Manager, error) { + if options.Discoverer == nil { + options.Discoverer = modem.NewSystemDiscoverer() + } + if options.Opener == nil { + options.Opener = modem.SerialOpener{} + } + if options.CommandTimeout <= 0 { + options.CommandTimeout = 3 * time.Second + } + if options.LongTimeout <= 0 { + options.LongTimeout = 45 * time.Second + } + if options.SMSTimeout <= 0 { + // Quectel documents a maximum AT+CMGS response time of 120 seconds. + options.SMSTimeout = 125 * time.Second + } + if options.ScanTimeout <= 0 { + // AT+COPS=? can take well over a minute while the modem sweeps every band. + options.ScanTimeout = 150 * time.Second + } + return &Manager{ + discoverer: options.Discoverer, + opener: options.Opener, + commandTimeout: options.CommandTimeout, + longTimeout: options.LongTimeout, + smsTimeout: options.SMSTimeout, + scanTimeout: options.ScanTimeout, + devices: make(map[string]*managedDevice), + ussdSessions: make(map[string]ussdSession), + esimRecoveries: make(map[string]chan struct{}), + esimCache: make(map[string]EsimInfo), + }, nil +} + +func (manager *Manager) Start(ctx context.Context) error { + manager.mu.Lock() + if manager.started { + manager.mu.Unlock() + return nil + } + manager.mu.Unlock() + + if _, err := manager.Discover(ctx); err != nil { + return err + } + manager.mu.Lock() + manager.started = true + manager.mu.Unlock() + return nil +} + +func (manager *Manager) Stop(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + manager.mu.Lock() + manager.started = false + states := make([]*managedDevice, 0, len(manager.devices)) + for _, state := range manager.devices { + states = append(states, state) + } + manager.mu.Unlock() + + var closeErrors []error + for _, state := range states { + if err := ctx.Err(); err != nil { + return errors.Join(append(closeErrors, err)...) + } + state.opMu.Lock() + if state.client != nil { + if err := state.client.Close(); err != nil { + closeErrors = append(closeErrors, err) + } + state.client = nil + } + state.opMu.Unlock() + } + return errors.Join(closeErrors...) +} + +func (manager *Manager) Discover(ctx context.Context) ([]Device, error) { + if ctx == nil { + ctx = context.Background() + } + candidates, err := manager.discoverer.Discover(ctx) + if err != nil { + return nil, err + } + seen := make(map[string]struct{}, len(candidates)) + + manager.mu.Lock() + for _, candidate := range candidates { + if strings.TrimSpace(candidate.ID) == "" { + continue + } + seen[candidate.ID] = struct{}{} + state := manager.devices[candidate.ID] + if state == nil { + manager.devices[candidate.ID] = &managedDevice{ + candidate: candidate, + discovered: true, + } + continue + } + if state.candidate.ATPort.OpenPath() != candidate.ATPort.OpenPath() { + state.resetClientOnLock = true + } + state.candidate = candidate + state.discovered = true + } + var stale []*managedDevice + for id, state := range manager.devices { + if _, ok := seen[id]; ok { + continue + } + state.discovered = false + stale = append(stale, state) + } + manager.mu.Unlock() + + for _, state := range stale { + state.opMu.Lock() + if state.client != nil { + _ = state.client.Close() + state.client = nil + } + state.opMu.Unlock() + } + manager.resetChangedClients() + return manager.List(), nil +} + +func (manager *Manager) resetChangedClients() { + manager.mu.Lock() + states := make([]*managedDevice, 0, len(manager.devices)) + for _, state := range manager.devices { + if state.resetClientOnLock { + states = append(states, state) + state.resetClientOnLock = false + } + } + manager.mu.Unlock() + for _, state := range states { + state.opMu.Lock() + if state.client != nil { + _ = state.client.Close() + state.client = nil + } + state.opMu.Unlock() + } +} + +func (manager *Manager) List() []Device { + manager.mu.RLock() + result := make([]Device, 0, len(manager.devices)) + for id, state := range manager.devices { + result = append(result, copyDevice(id, state)) + } + manager.mu.RUnlock() + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func (manager *Manager) Get(id string) (Device, error) { + manager.mu.RLock() + state := manager.devices[id] + if state == nil { + manager.mu.RUnlock() + return Device{}, ErrNotFound + } + result := copyDevice(id, state) + manager.mu.RUnlock() + return result, nil +} + +func copyDevice(id string, state *managedDevice) Device { + var snapshot *Snapshot + if state.snapshot != nil { + value := *state.snapshot + value.Warnings = append([]string(nil), value.Warnings...) + snapshot = &value + } + return Device{ + ID: id, + Candidate: copyCandidate(state.candidate), + Snapshot: snapshot, + LastError: state.lastError, + Discovered: state.discovered, + LastUpdated: state.lastUpdated, + } +} + +func copyCandidate(candidate modem.Candidate) modem.Candidate { + candidate.Ports = append([]modem.Port(nil), candidate.Ports...) + return candidate +} + +func (manager *Manager) lookup(id string) (*managedDevice, error) { + manager.mu.RLock() + defer manager.mu.RUnlock() + if !manager.started { + return nil, ErrNotStarted + } + state := manager.devices[id] + if state == nil || !state.discovered { + return nil, ErrNotFound + } + return state, nil +} + +func (manager *Manager) clientLocked( + ctx context.Context, + state *managedDevice, + candidate modem.Candidate, +) (modem.Client, error) { + if state.client != nil { + if poisoned, ok := state.client.(modem.PoisonedClient); ok && poisoned.Poisoned() { + // The cached session hit a transport-fatal error (a failed + // write/drain/read or a closed serial line); the underlying fd is + // wedged and every subsequent command reuses the corpse, so the + // device stays stuck on EIO forever. Discard it and reopen so the + // next AT/CSIM call self-heals. AT-level failures (CommandError, + // command timeout) do not poison — those leave a healthy transport + // that reopening would only destroy over a transient +CME ERROR. + _ = state.client.Close() + state.client = nil + } else { + return state.client, nil + } + } + if !candidate.HasATPort() { + return nil, ErrNoATPort + } + client, err := manager.opener.Open(ctx, candidate.ATPort) + if err != nil { + return nil, err + } + state.client = client + return client, nil +} + +func (manager *Manager) setResult( + id string, + state *managedDevice, + snapshot *Snapshot, + err error, +) { + manager.mu.Lock() + defer manager.mu.Unlock() + if manager.devices[id] != state { + return + } + if snapshot != nil { + value := *snapshot + value.Warnings = append([]string(nil), snapshot.Warnings...) + state.snapshot = &value + state.lastUpdated = snapshot.UpdatedAt + } + if err != nil { + state.lastError = err.Error() + } else { + state.lastError = "" + } +} + +func (manager *Manager) candidateFor(state *managedDevice) modem.Candidate { + manager.mu.RLock() + defer manager.mu.RUnlock() + return copyCandidate(state.candidate) +} + +func (manager *Manager) validateActive( + id string, + state *managedDevice, +) error { + manager.mu.RLock() + defer manager.mu.RUnlock() + if !manager.started { + return ErrNotStarted + } + current := manager.devices[id] + if current != state || !state.discovered { + return ErrNotFound + } + return nil +} + +func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error) { + state, err := manager.lookup(id) + if err != nil { + return Snapshot{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return Snapshot{}, err + } + candidate := manager.candidateFor(state) + client, err := manager.clientLocked(ctx, state, candidate) + if err != nil { + manager.setResult(id, state, nil, err) + return Snapshot{}, err + } + snapshot, err := manager.readSnapshot(ctx, id, candidate, client) + manager.setResult(id, state, &snapshot, err) + return snapshot, err +} + +func (manager *Manager) ExecuteAT( + ctx context.Context, + id string, + command string, +) (modem.Response, error) { + state, err := manager.lookup(id) + if err != nil { + return modem.Response{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return modem.Response{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return modem.Response{}, err + } + commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout) + defer cancel() + response, err := client.Execute(commandCtx, command) + manager.setResult(id, state, nil, err) + return response, err +} + +// ExecuteSensitiveAT runs an AT command whose payload contains short-lived +// authentication material. The original transport error is returned to the +// caller, but it is never retained in the device snapshot because a +// modem.CommandError may include the full command. +func (manager *Manager) ExecuteSensitiveAT( + ctx context.Context, + id string, + command string, +) (modem.Response, error) { + state, err := manager.lookup(id) + if err != nil { + return modem.Response{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return modem.Response{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult( + id, + state, + nil, + errors.New("sensitive AT command could not open the modem"), + ) + return modem.Response{}, err + } + commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout) + defer cancel() + response, err := client.Execute(commandCtx, command) + recordedErr := err + if err != nil { + recordedErr = errors.New("sensitive AT command failed") + } + manager.setResult(id, state, nil, recordedErr) + return response, err +} + +func (manager *Manager) Reboot(ctx context.Context, id string) error { + state, err := manager.lookup(id) + if err != nil { + return err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return err + } + commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout) + defer cancel() + _, err = client.Execute(commandCtx, "AT+CFUN=1,1") + if closeErr := client.Close(); err == nil { + err = closeErr + } + state.client = nil + state.preFlightMode = nil + manager.clearSnapshot(id, state) + manager.setResult(id, state, nil, err) + return err +} + +// rebootForProfileSwitch is the post-EnableProfile modem reset. After the eUICC +// marks a new profile active, the modem keeps the old SIM cached and lands in +// SIM failure (-CME 13) until it is bounced. ESIMSwitchProfile has already +// released opMu by the time it calls this, so the reset is safe to take the +// lock. This mirrors Reboot but is separate so the call site can't recurse into +// a guarded-reset path. +func (manager *Manager) rebootForProfileSwitch(ctx context.Context, id string) error { + state, err := manager.lookup(id) + if err != nil { + return err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return err + } + commandCtx, cancel := manager.withTimeout(ctx, manager.longTimeout) + defer cancel() + _, err = client.Execute(commandCtx, "AT+CFUN=1,1") + if closeErr := client.Close(); err == nil { + err = closeErr + } + state.client = nil + state.preFlightMode = nil + manager.clearSnapshot(id, state) + manager.setResult(id, state, nil, err) + return err +} + +func (manager *Manager) clearSnapshot(id string, state *managedDevice) { + manager.mu.Lock() + defer manager.mu.Unlock() + if manager.devices[id] == state { + state.snapshot = nil + } +} + +func (manager *Manager) withTimeout( + ctx context.Context, + timeout time.Duration, +) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + if _, ok := ctx.Deadline(); ok { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, timeout) +} + +func (manager *Manager) command( + ctx context.Context, + client modem.Client, + command string, +) (modem.Response, error) { + commandCtx, cancel := manager.withTimeout(ctx, manager.commandTimeout) + defer cancel() + response, err := client.Execute(commandCtx, command) + if err != nil { + return response, fmt.Errorf("%s: %w", command, err) + } + return response, nil +} diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go new file mode 100644 index 0000000..4e258f7 --- /dev/null +++ b/internal/device/manager_test.go @@ -0,0 +1,141 @@ +package device + +import ( + "context" + "errors" + "testing" + + "vocat/internal/modem" +) + +func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + { + command: "ATI", + response: okResponse( + "Quectel", + "EC20CEFAGR06A04M4G", + "Revision: EC20CEHCLGR06A04M1G", + ), + }, + {command: "AT+CPIN?", response: okResponse("+CPIN: READY")}, + {command: "AT+CSQ", response: okResponse("+CSQ: 20,99")}, + { + command: `AT+QENG="servingcell"`, + response: okResponse( + `+QENG: "servingcell","NOCONN","LTE","FDD",460,01,5F1E805,37,1650,3,5,5,8340,-97,-10,-68,15,9`, + ), + }, + {command: "AT+COPS?", response: okResponse(`+COPS: 0,0,"China Mobile",7`)}, + {command: "AT+CGSN", response: okResponse("867123456789012")}, + { + command: "AT+CCID", + response: modem.Response{Final: "+CME ERROR: 100"}, + err: errors.New("CCID unsupported"), + }, + {command: "AT+QCCID", response: okResponse("+QCCID: 8986001234567890123F")}, + {command: "AT+CIMI", response: okResponse("460001234567890")}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, + {command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)}, + }} + manager, id := newStartedTestManager(t, client) + + snapshot, err := manager.Refresh(context.Background(), id) + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if !snapshot.Responsive || !snapshot.SIMReady || snapshot.SIMStatus != "ready" { + t.Fatalf("modem/SIM state = %#v", snapshot) + } + if snapshot.Manufacturer != "Quectel" || + snapshot.Model != "EC20CEFAGR06A04M4G" || + snapshot.Firmware != "EC20CEHCLGR06A04M1G" { + t.Fatalf( + "identity = manufacturer %q, model %q, firmware %q", + snapshot.Manufacturer, + snapshot.Model, + snapshot.Firmware, + ) + } + if snapshot.SignalRaw == nil || *snapshot.SignalRaw != 20 || + snapshot.SignalPercent == nil || *snapshot.SignalPercent != 65 || + snapshot.RSSIDBm == nil || *snapshot.RSSIDBm != -68 || + snapshot.RSRP == nil || *snapshot.RSRP != -97 || + snapshot.RSRQ == nil || *snapshot.RSRQ != -10 || + snapshot.SINR == nil || *snapshot.SINR != 15 { + t.Fatalf("signal metrics = %#v", snapshot) + } + if snapshot.AccessTech != "LTE" || snapshot.Band != "B3" || + snapshot.Channel != "1650" || snapshot.OperatorName != "China Mobile" { + t.Fatalf("network = %#v", snapshot) + } + if snapshot.IMEI != "867123456789012" || + snapshot.ICCID != "8986001234567890123" || + snapshot.IMSI != "460001234567890" { + t.Fatalf("subscriber identifiers = %#v", snapshot) + } + if !snapshot.ModeKnown || snapshot.OperatingMode != 1 || + snapshot.FlightMode || snapshot.RadioOff { + t.Fatalf("operating mode = %#v", snapshot) + } + if snapshot.Phone.Number != "+8613800138000" || + snapshot.Phone.Source != PhoneSourceCNUM { + t.Fatalf("phone = %#v", snapshot.Phone) + } + + device, err := manager.Get(id) + if err != nil { + t.Fatalf("Get: %v", err) + } + if device.Snapshot == nil || device.Snapshot.Phone.Number != snapshot.Phone.Number { + t.Fatalf("stored device = %#v", device) + } + client.assertDone(t) +} + +func TestParseICCIDIdentifierStripsTwoFillerNibbles(t *testing.T) { + response := modem.Response{Lines: []string{"+CCID: 894921007608519523FF"}} + if got := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != "894921007608519523" { + t.Fatalf("parseICCIDIdentifier = %q", got) + } +} + +func TestManagerRequiresStartAndKnownDevice(t *testing.T) { + manager, err := NewManager(Options{ + Discoverer: staticDiscoverer{}, + Opener: &staticOpener{}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := manager.Refresh(context.Background(), "missing"); !errors.Is(err, ErrNotStarted) { + t.Fatalf("before Start error = %v", err) + } + if err := manager.Start(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := manager.Refresh(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown device error = %v", err) + } +} + +func TestExecuteSensitiveATDoesNotPersistCommandOrModemError(t *testing.T) { + const secretCommand = `AT+CSIM=78,"00880081221000112233445566778899AABBCCDDEEFF1000112233445566778899AABBCCDDEEFF00"` + client := &transcriptClient{steps: []clientStep{{ + command: secretCommand, + err: &modem.CommandError{Command: secretCommand, Final: "ERROR"}, + }}} + manager, id := newStartedTestManager(t, client) + + if _, err := manager.ExecuteSensitiveAT(context.Background(), id, secretCommand); err == nil { + t.Fatal("ExecuteSensitiveAT() error = nil") + } + entry, err := manager.Get(id) + if err != nil { + t.Fatal(err) + } + if entry.LastError != "sensitive AT command failed" { + t.Fatalf("LastError = %q", entry.LastError) + } + client.assertDone(t) +} diff --git a/internal/device/phone.go b/internal/device/phone.go new file mode 100644 index 0000000..91f5cb4 --- /dev/null +++ b/internal/device/phone.go @@ -0,0 +1,336 @@ +package device + +import ( + "context" + "encoding/hex" + "fmt" + "regexp" + "strconv" + "strings" + "time" + + "vocat/internal/modem" +) + +var phonePattern = regexp.MustCompile(`\+?[0-9][0-9 ()-]{3,}[0-9]`) + +func (manager *Manager) readPhoneNumber( + ctx context.Context, + client modem.Client, +) (PhoneNumber, []string) { + var warnings []string + if response, err := manager.command(ctx, client, "AT+CNUM"); err == nil { + if number := parsePhoneResponse(response, "+CNUM:"); number != "" { + return PhoneNumber{ + Number: number, + Source: PhoneSourceCNUM, + Status: "号码来自模块/SIM 的 CNUM 记录", + }, warnings + } + } else { + warnings = append(warnings, "read CNUM: "+err.Error()) + } + + phonebook, phonebookWarnings := manager.readOwnNumberPhonebook(ctx, client) + warnings = append(warnings, phonebookWarnings...) + if phonebook != "" { + return PhoneNumber{ + Number: phonebook, + Source: PhoneSourceOwnNumber, + Status: "号码来自 SIM Own Numbers 电话簿", + }, warnings + } + + raw, rawWarnings := manager.readEFMSISDN(ctx, client) + warnings = append(warnings, rawWarnings...) + if raw != "" { + return PhoneNumber{ + Number: raw, + Source: PhoneSourceEFMSISDN, + Status: "号码来自 USIM EF_MSISDN 只读记录", + }, warnings + } + return PhoneNumber{ + Status: "CNUM、Own Numbers 与 EF_MSISDN 均为空;号码不能由 IMSI/ICCID 推导,需要运营商或 IMS/VoWiFi 注册侧提供", + }, warnings +} + +func (manager *Manager) readOwnNumberPhonebook( + ctx context.Context, + client modem.Client, +) (number string, warnings []string) { + previous := "" + if response, err := manager.command(ctx, client, "AT+CPBS?"); err == nil { + previous = parseSelectedPhonebook(response) + } else { + warnings = append(warnings, "query current phonebook: "+err.Error()) + } + response, err := manager.command(ctx, client, `AT+CPBS="ON"`) + if err != nil || !response.OK() { + if err != nil { + warnings = append(warnings, "select Own Numbers phonebook: "+err.Error()) + } + return "", warnings + } + if previous != "" && previous != "ON" { + defer func() { + restoreCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if _, restoreErr := manager.command( + restoreCtx, + client, + fmt.Sprintf(`AT+CPBS="%s"`, previous), + ); restoreErr != nil { + warnings = append(warnings, "restore phonebook: "+restoreErr.Error()) + } + }() + } + + start, end := 1, 2 + if response, rangeErr := manager.command(ctx, client, "AT+CPBR=?"); rangeErr == nil { + if parsedStart, parsedEnd, ok := parsePhonebookRange(response); ok { + start, end = parsedStart, parsedEnd + } + } else { + warnings = append(warnings, "query Own Numbers range: "+rangeErr.Error()) + } + if end > start+9 { + end = start + 9 + } + for index := start; index <= end; index++ { + response, readErr := manager.command(ctx, client, fmt.Sprintf("AT+CPBR=%d", index)) + if readErr != nil { + continue + } + if number := parsePhoneResponse(response, "+CPBR:"); number != "" { + return number, warnings + } + } + return "", warnings +} + +func parseSelectedPhonebook(response modem.Response) string { + value := valueAfterPrefix(response, "+CPBS:") + values := csvValues(value) + if len(values) == 0 { + return "" + } + selected := strings.ToUpper(strings.Trim(values[0], `"`)) + if len(selected) < 1 || len(selected) > 4 { + return "" + } + for _, character := range selected { + if character < 'A' || character > 'Z' { + return "" + } + } + return selected +} + +func parsePhonebookRange(response modem.Response) (int, int, bool) { + pattern := regexp.MustCompile(`\((\d+)-(\d+)\)`) + for _, line := range response.Lines { + match := pattern.FindStringSubmatch(line) + if len(match) != 3 { + continue + } + start, startErr := strconv.Atoi(match[1]) + end, endErr := strconv.Atoi(match[2]) + if startErr == nil && endErr == nil && start > 0 && end >= start { + return start, end, true + } + } + return 0, 0, false +} + +func parsePhoneResponse(response modem.Response, prefix string) string { + for _, line := range response.Lines { + if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), strings.ToUpper(prefix)) { + continue + } + for _, match := range phonePattern.FindAllString(line, -1) { + if number := normalizePhoneNumber(match); number != "" { + return number + } + } + } + return "" +} + +func normalizePhoneNumber(value string) string { + var result strings.Builder + for _, character := range strings.TrimSpace(value) { + switch { + case character >= '0' && character <= '9': + result.WriteRune(character) + case character == '+' && result.Len() == 0: + result.WriteRune(character) + case character == ' ' || character == '-' || character == '(' || character == ')': + default: + return "" + } + } + number := result.String() + digits := strings.TrimPrefix(number, "+") + if len(digits) < 5 || len(digits) > 20 { + return "" + } + return number +} + +type crsmPath struct { + value string +} + +func (manager *Manager) readEFMSISDN( + ctx context.Context, + client modem.Client, +) (string, []string) { + var warnings []string + for _, path := range []crsmPath{{}, {"3F007FFF"}, {"3F007F10"}} { + recordLength, recordCount, ok := 0, 0, false + for _, responseLength := range []int{0, 15} { + status, err := manager.command( + ctx, + client, + crsmCommand(192, 0, responseLength, path.value), + ) + if err != nil { + warnings = append( + warnings, + fmt.Sprintf( + "read EF_MSISDN metadata (path %q, length %d): %v", + path.value, + responseLength, + err, + ), + ) + continue + } + recordLength, recordCount, ok = msisdnRecordShape(status) + if ok { + break + } + } + if !ok { + continue + } + if recordCount > 10 { + recordCount = 10 + } + for index := 1; index <= recordCount; index++ { + record, readErr := manager.command( + ctx, + client, + crsmCommand(178, index, recordLength, path.value), + ) + if readErr != nil { + warnings = append( + warnings, + fmt.Sprintf( + "read EF_MSISDN record %d (path %q): %v", + index, + path.value, + readErr, + ), + ) + continue + } + if number := decodeMSISDNRecord(record); number != "" { + return number, warnings + } + } + } + return "", warnings +} + +func crsmCommand(operation, index, length int, path string) string { + p2 := 0 + if operation == 178 { + p2 = 4 + } + command := fmt.Sprintf("AT+CRSM=%d,28480,%d,%d,%d", operation, index, p2, length) + if path != "" { + command += fmt.Sprintf(`,"","%s"`, path) + } + return command +} + +func crsmPayload(response modem.Response) []byte { + value := valueAfterPrefix(response, "+CRSM:") + values := csvValues(value) + if len(values) < 3 { + return nil + } + sw1, sw1Err := strconv.Atoi(values[0]) + sw2, sw2Err := strconv.Atoi(values[1]) + if sw1Err != nil || sw2Err != nil || + !((sw1 == 144 && sw2 == 0) || sw1 == 145) { + return nil + } + payload := strings.Trim(values[2], `" `) + decoded, err := hex.DecodeString(payload) + if err != nil { + return nil + } + return decoded +} + +func msisdnRecordShape(response modem.Response) (recordLength, recordCount int, ok bool) { + payload := crsmPayload(response) + for index := 0; index+1 < len(payload); index++ { + tag := payload[index] + length := int(payload[index+1]) + start := index + 2 + end := start + length + if end > len(payload) { + continue + } + if tag == 0x82 && length >= 5 { + recordLength = int(payload[end-3])<<8 | int(payload[end-2]) + recordCount = int(payload[end-1]) + if recordLength >= 14 && recordLength <= 255 && recordCount > 0 { + return recordLength, recordCount, true + } + } + } + if len(payload) >= 15 && + payload[4] == 0x6f && payload[5] == 0x40 && + payload[6] == 0x04 && + (payload[13] == 0x01 || payload[13] == 0x03) { + fileSize := int(payload[2])<<8 | int(payload[3]) + recordLength = int(payload[14]) + if recordLength >= 14 && fileSize >= recordLength { + return recordLength, fileSize / recordLength, true + } + } + return 0, 0, false +} + +func decodeMSISDNRecord(response modem.Response) string { + payload := crsmPayload(response) + if len(payload) < 14 { + return "" + } + footer := payload[len(payload)-14:] + storedLength := int(footer[0]) + if storedLength < 2 || storedLength == 0xff { + return "" + } + bcdLength := storedLength - 1 + if bcdLength > 10 { + bcdLength = 10 + } + var result strings.Builder + if footer[1]&0x70 == 0x10 { + result.WriteByte('+') + } + for _, value := range footer[2 : 2+bcdLength] { + for _, digit := range []byte{value & 0x0f, value >> 4} { + if digit <= 9 { + result.WriteByte('0' + digit) + } + } + } + return normalizePhoneNumber(result.String()) +} diff --git a/internal/device/phone_test.go b/internal/device/phone_test.go new file mode 100644 index 0000000..b9d99fe --- /dev/null +++ b/internal/device/phone_test.go @@ -0,0 +1,88 @@ +package device + +import ( + "context" + "errors" + "strings" + "testing" + + "vocat/internal/modem" +) + +func TestReadPhoneNumberFallsBackToOwnNumbersAndRestoresPhonebook(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CNUM", response: okResponse()}, + {command: "AT+CPBS?", response: okResponse(`+CPBS: "SM",0,250`)}, + {command: `AT+CPBS="ON"`, response: okResponse()}, + {command: "AT+CPBR=?", response: okResponse("+CPBR: (1-3),40,20")}, + {command: "AT+CPBR=1", response: okResponse(`+CPBR: 1,"",129,""`)}, + { + command: "AT+CPBR=2", + response: okResponse(`+CPBR: 2,"+44 7700 900123",145,"Own"`), + }, + { + command: `AT+CPBS="SM"`, + err: errors.New("restore failed"), + }, + }} + manager, err := NewManager(Options{}) + if err != nil { + t.Fatal(err) + } + + phone, warnings := manager.readPhoneNumber(context.Background(), client) + if phone.Number != "+447700900123" || phone.Source != PhoneSourceOwnNumber { + t.Fatalf("phone = %#v", phone) + } + if len(warnings) != 1 || !strings.Contains(warnings[0], "restore phonebook") { + t.Fatalf("warnings = %#v", warnings) + } + client.assertDone(t) +} + +func TestReadPhoneNumberFallsBackToEFMSISDN(t *testing.T) { + record := strings.Repeat("FF", 18) + + "0791447700091032FFFFFFFFFFFF" + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CNUM", response: okResponse()}, + {command: "AT+CPBS?", response: okResponse(`+CPBS: "SM",0,250`)}, + { + command: `AT+CPBS="ON"`, + err: errors.New("Own Numbers unavailable"), + }, + { + command: "AT+CRSM=192,28480,0,0,0", + err: errors.New("zero-length GET RESPONSE unsupported"), + }, + { + command: "AT+CRSM=192,28480,0,0,15", + response: okResponse(`+CRSM: 144,0,"62198205422100200283026F408A01"`), + }, + { + command: "AT+CRSM=178,28480,1,4,32", + response: okResponse(`+CRSM: 144,0,"` + record + `"`), + }, + }} + manager, err := NewManager(Options{}) + if err != nil { + t.Fatal(err) + } + + phone, warnings := manager.readPhoneNumber(context.Background(), client) + if phone.Number != "+447700900123" || phone.Source != PhoneSourceEFMSISDN { + t.Fatalf("phone = %#v; warnings = %#v", phone, warnings) + } + client.assertDone(t) +} + +func TestPhoneNumberIsNeverDerivedFromSubscriberIdentifiers(t *testing.T) { + if got := parsePhoneResponse( + modem.Response{Lines: []string{"+CNUM: ,,,"}}, + "+CNUM:", + ); got != "" { + t.Fatalf("empty CNUM parsed as %q", got) + } + if got := normalizePhoneNumber("460001234567890/89860012345678901234"); got != "" { + t.Fatalf("invalid combined subscriber identifiers parsed as %q", got) + } +} diff --git a/internal/device/region.go b/internal/device/region.go new file mode 100644 index 0000000..9196bb4 --- /dev/null +++ b/internal/device/region.go @@ -0,0 +1,73 @@ +package device + +import ( + "fmt" + "strings" + "unicode" + + "vocat/internal/i18n" +) + +// BlockedMCCs lists the mobile country codes whose SIM cards must not be +// served by this product. The product does not provide service to mainland +// China cards; the set mirrors the CN entry of the MCC table used for upstream +// proxy routing (460/461). It is keyed by MCC with a display name for logs and +// user-facing messaging. +var BlockedMCCs = map[string]string{ + "460": "中国", + "461": "中国", +} + +// CardMCCMNC splits an IMSI into its mobile country code and mobile network +// code. The MCC is the leading three digits and the MNC the following two or +// three. Empty strings are returned for an unusable IMSI. +func CardMCCMNC(imsi string) (mcc string, mnc string) { + digits := strings.TrimSpace(imsi) + if len(digits) < 5 || + strings.IndexFunc(digits, func(r rune) bool { return !unicode.IsDigit(r) }) >= 0 { + return "", "" + } + mcc = digits[:3] + mnc = digits[3:] + if len(mnc) > 3 { + mnc = mnc[:3] + } + return mcc, mnc +} + +// RegionBlockReason returns a human-readable reason when the SIM identified by +// the IMSI belongs to a blocked region. It returns an empty string when the +// card is allowed or when the IMSI is unavailable: only a confirmed blocked +// MCC triggers a block (fail-open), so a transient IMSI read failure never +// denies service to a legitimate card. +func RegionBlockReason(imsi string) string { + mcc, _ := CardMCCMNC(imsi) + country, blocked := BlockedMCCs[mcc] + if !blocked { + return "" + } + return i18n.Tf("SIM 卡归属地为%s(MCC %s),本服务不向该地区卡片提供数据/短信/VoWiFi", i18n.T(country), mcc) +} + +// regionBlockError reports whether the currently inserted SIM must not be +// served. It reads only the cached snapshot IMSI and never issues an extra AT +// command, so it adds no modem round-trip and leaves guarded command +// transcripts untouched. A missing snapshot or IMSI yields nil (fail-open); +// the periodic region enforcement forces airplane mode as the authoritative +// backstop. +func (manager *Manager) regionBlockError(state *managedDevice) error { + manager.mu.RLock() + var snapshot *Snapshot + if state.snapshot != nil { + value := *state.snapshot + snapshot = &value + } + manager.mu.RUnlock() + if snapshot == nil { + return nil + } + if reason := RegionBlockReason(snapshot.IMSI); reason != "" { + return fmt.Errorf("%w: %s", ErrRegionBlocked, reason) + } + return nil +} diff --git a/internal/device/region_test.go b/internal/device/region_test.go new file mode 100644 index 0000000..393de00 --- /dev/null +++ b/internal/device/region_test.go @@ -0,0 +1,116 @@ +package device + +import ( + "context" + "errors" + "strings" + "testing" +) + +// injectSnapshot stores a snapshot on the managed device so region guards observe +// its IMSI without replaying the full readSnapshot AT transcript. +func injectSnapshot(t *testing.T, manager *Manager, id string, snapshot *Snapshot) { + t.Helper() + state, err := manager.lookup(id) + if err != nil { + t.Fatalf("lookup: %v", err) + } + manager.setResult(id, state, snapshot, nil) +} + +func TestCardMCCMNC(t *testing.T) { + t.Parallel() + if mcc, _ := CardMCCMNC("460001234567890"); mcc != "460" { + t.Fatalf("CardMCCMNC mcc = %q, want 460", mcc) + } + for _, bad := range []string{"", "4600", "4600X1234"} { + if mcc, _ := CardMCCMNC(bad); mcc != "" { + t.Fatalf("CardMCCMNC(%q) mcc = %q, want empty", bad, mcc) + } + } +} + +func TestRegionBlockReason(t *testing.T) { + t.Parallel() + for _, imsi := range []string{"460001234567890", "461001234567890"} { + reason := RegionBlockReason(imsi) + if reason == "" { + t.Fatalf("RegionBlockReason(%q) did not block", imsi) + } + if !strings.Contains(reason, "中国") { + t.Fatalf("RegionBlockReason(%q) = %q, want it to name 中国", imsi, reason) + } + } + for _, imsi := range []string{"310260123456789", "001011234567890", ""} { + if reason := RegionBlockReason(imsi); reason != "" { + t.Fatalf("RegionBlockReason(%q) = %q, want empty (fail-open)", imsi, reason) + } + } +} + +func TestSetNetworkBlockedForRestrictedRegionSIM(t *testing.T) { + client := &transcriptClient{} + manager, id := newStartedTestManager(t, client) + injectSnapshot(t, manager, id, &Snapshot{DeviceID: id, IMSI: "460001234567890"}) + _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{ + Enabled: true, APN: "internet", IPVersion: "IPV4V6", + }) + if !errors.Is(err, ErrRegionBlocked) { + t.Fatalf("error = %v, want ErrRegionBlocked", err) + } + client.assertDone(t) +} + +func TestSetNetworkAllowedForServedRegionSIM(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: `AT+CGDCONT=1,"IPV4V6","internet"`, response: okResponse()}, + {command: "AT+CGATT=1", response: okResponse()}, + {command: "AT+CGACT=1,1", response: okResponse()}, + }} + manager, id := newStartedTestManager(t, client) + injectSnapshot(t, manager, id, &Snapshot{DeviceID: id, IMSI: "310260123456789"}) + result, err := manager.SetNetwork(context.Background(), id, NetworkRequest{ + Enabled: true, APN: "internet", IPVersion: "IPV4V6", + }) + if err != nil { + t.Fatalf("enable network: %v", err) + } + if !result.Enabled { + t.Fatalf("enable result = %#v", result) + } + client.assertDone(t) +} + +// A device whose SIM region is not yet known (no snapshot) must not be denied: +// only a confirmed blocked MCC blocks service (fail-open). +func TestSetNetworkAllowedWhenSIMRegionUnknown(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: `AT+CGDCONT=1,"IPV4V6","internet"`, response: okResponse()}, + {command: "AT+CGATT=1", response: okResponse()}, + {command: "AT+CGACT=1,1", response: okResponse()}, + }} + manager, id := newStartedTestManager(t, client) + if _, err := manager.SetNetwork(context.Background(), id, NetworkRequest{ + Enabled: true, APN: "internet", IPVersion: "IPV4V6", + }); err != nil { + t.Fatalf("enable network with unknown region: %v", err) + } + client.assertDone(t) +} + +func TestSendSMSBlockedForRestrictedRegionSIM(t *testing.T) { + client := &transcriptClient{} + manager, id := newStartedTestManager(t, client) + injectSnapshot(t, manager, id, &Snapshot{DeviceID: id, IMSI: "460001234567890"}) + result, err := manager.SendSMS(context.Background(), id, "+15551234567", "hello") + if !errors.Is(err, ErrRegionBlocked) { + t.Fatalf("error = %v, want ErrRegionBlocked", err) + } + if result.PartsAttempted != 0 { + t.Fatalf("PartsAttempted = %d, want 0 for a blocked region", result.PartsAttempted) + } + if result.SubmissionStatus != "region_blocked" { + t.Fatalf("SubmissionStatus = %q, want region_blocked", result.SubmissionStatus) + } + client.assertDone(t) +} diff --git a/internal/device/scan.go b/internal/device/scan.go new file mode 100644 index 0000000..d60a83e --- /dev/null +++ b/internal/device/scan.go @@ -0,0 +1,131 @@ +package device + +import ( + "context" + "strings" + + "vocat/internal/modem" +) + +// ScannedOperator is one network reported by an operator scan (AT+COPS=?). +type ScannedOperator struct { + // Status is "current", "available", "forbidden", or "unknown". + Status string `json:"status"` + Name string `json:"name"` + Short string `json:"shortName,omitempty"` + Numeric string `json:"numeric"` + Act string `json:"act,omitempty"` +} + +// OperatorScanResult is the outcome of a full operator scan. +type OperatorScanResult struct { + Status string `json:"status"` // "complete" or "failed" + Operators []ScannedOperator `json:"operators"` +} + +// ScanOperators runs AT+COPS=? to list the networks the modem can currently +// see. The command is slow (tens of seconds, up to the modem's documented +// ceiling), so it uses the manager's scan timeout rather than the normal +// command timeout. It is abortable through the caller's context. +func (manager *Manager) ScanOperators( + ctx context.Context, + id string, +) (OperatorScanResult, error) { + state, err := manager.lookup(id) + if err != nil { + return OperatorScanResult{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return OperatorScanResult{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorScanResult{}, err + } + scanContext, cancel := manager.withTimeout(ctx, manager.scanTimeout) + defer cancel() + response, err := client.Execute(scanContext, "AT+COPS=?") + if err != nil { + manager.setResult(id, state, nil, err) + return OperatorScanResult{Status: "failed", Operators: []ScannedOperator{}}, err + } + result := OperatorScanResult{ + Status: "complete", + Operators: parseOperatorScan(response), + } + manager.setResult(id, state, nil, nil) + return result, nil +} + +// parseOperatorScan parses the +COPS: list returned by AT+COPS=?. Each entry is +// a parenthesised tuple (stat,"long","short","numeric"[,act]). +func parseOperatorScan(response modem.Response) []ScannedOperator { + operators := make([]ScannedOperator, 0) + for _, line := range response.Lines { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToUpper(trimmed), "+COPS:") { + continue + } + payload := strings.TrimSpace(trimmed[len("+COPS:"):]) + for _, tuple := range extractScanTuples(payload) { + fields := csvValues(tuple) + if len(fields) < 4 { + continue + } + operator := ScannedOperator{ + Status: operatorScanStatus(fields[0]), + Name: fields[1], + Short: fields[2], + Numeric: fields[3], + } + if len(fields) >= 5 { + operator.Act = accessTechnology(fields[4]) + } + operators = append(operators, operator) + } + } + return operators +} + +// extractScanTuples returns the contents of each top-level parenthesised group, +// ignoring parentheses inside quoted strings. +func extractScanTuples(payload string) []string { + tuples := make([]string, 0) + depth := 0 + start := -1 + inQuote := false + for index, r := range payload { + switch { + case r == '"': + inQuote = !inQuote + case r == '(' && !inQuote: + if depth == 0 { + start = index + 1 + } + depth++ + case r == ')' && !inQuote: + depth-- + if depth == 0 && start >= 0 { + tuples = append(tuples, payload[start:index]) + start = -1 + } + } + } + return tuples +} + +func operatorScanStatus(code string) string { + switch strings.TrimSpace(code) { + case "1": + return "available" + case "2": + return "current" + case "3": + return "forbidden" + default: + return "unknown" + } +} diff --git a/internal/device/scan_ussd_test.go b/internal/device/scan_ussd_test.go new file mode 100644 index 0000000..117317b --- /dev/null +++ b/internal/device/scan_ussd_test.go @@ -0,0 +1,109 @@ +package device + +import ( + "context" + "errors" + "testing" +) + +func TestScanOperatorsParsesNetworks(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "AT+COPS=?", response: okResponse( + `+COPS: (2,"China Mobile","CMCC","46000",7),(1,"China Unicom","CU","46001",0),(3,"China Telecom","CT","46011",7)`, + )}, + }} + manager, id := newStartedTestManager(t, client) + result, err := manager.ScanOperators(context.Background(), id) + if err != nil { + t.Fatalf("ScanOperators: %v", err) + } + if result.Status != "complete" { + t.Fatalf("scan status = %q, want complete", result.Status) + } + if len(result.Operators) != 3 { + t.Fatalf("operators = %d, want 3 (%+v)", len(result.Operators), result.Operators) + } + first := result.Operators[0] + if first.Status != "current" || first.Numeric != "46000" || first.Name != "China Mobile" || first.Act != "LTE" { + t.Fatalf("first operator = %+v", first) + } + if result.Operators[2].Status != "forbidden" || result.Operators[2].Act != "LTE" { + t.Fatalf("third operator = %+v", result.Operators[2]) + } + client.assertDone(t) +} + +func TestParseOperatorScanHandlesEmptyAndMalformed(t *testing.T) { + if got := parseOperatorScan(okResponse()); len(got) != 0 { + t.Fatalf("empty response operators = %v", got) + } + if got := parseOperatorScan(okResponse(`+COPS: `)); len(got) != 0 { + t.Fatalf("empty list operators = %v", got) + } + // A tuple with too few fields is skipped, valid siblings still parse. + got := parseOperatorScan(okResponse(`+COPS: (1,"Only"),(1,"Good","G","31026",7)`)) + if len(got) != 1 || got[0].Numeric != "31026" { + t.Fatalf("mixed malformed operators = %+v", got) + } +} + +func TestUSSDSessionLifecycle(t *testing.T) { + client := &transcriptClient{ + steps: []clientStep{ + {command: `AT+CUSD=1,"*100#",15`, response: okResponse()}, + {command: `AT+CUSD=1,"1",15`, response: okResponse()}, + {command: "AT+CUSD=2", response: okResponse()}, + }, + urcs: []string{ + `+CUSD: 1,"Main menu",15`, + `+CUSD: 1,"Sub menu",15`, + }, + } + manager, id := newStartedTestManager(t, client) + + start, err := manager.USSD(context.Background(), id, "*100#") + if err != nil { + t.Fatalf("start USSD: %v", err) + } + if start.Status != "awaiting_input" || !start.Continueable || start.SessionID == "" { + t.Fatalf("start result = %+v, want an awaiting_input session", start) + } + if start.Text != "Main menu" { + t.Fatalf("start text = %q", start.Text) + } + + cont, err := manager.ContinueUSSD(context.Background(), start.SessionID, "1") + if err != nil { + t.Fatalf("continue USSD: %v", err) + } + if cont.Status != "awaiting_input" || cont.SessionID != start.SessionID || cont.Text != "Sub menu" { + t.Fatalf("continue result = %+v", cont) + } + + if err := manager.CancelUSSD(context.Background(), start.SessionID); err != nil { + t.Fatalf("cancel USSD: %v", err) + } + // After cancel the session is gone. + if _, err := manager.ContinueUSSD(context.Background(), start.SessionID, "1"); !errors.Is(err, ErrUSSDSessionNotFound) { + t.Fatalf("continue after cancel err = %v, want ErrUSSDSessionNotFound", err) + } + client.assertDone(t) +} + +func TestUSSDFinalAnswerNeedsNoSession(t *testing.T) { + client := &transcriptClient{ + steps: []clientStep{ + {command: `AT+CUSD=1,"*#06#",15`, response: okResponse()}, + }, + urcs: []string{`+CUSD: 0,"Your balance is 5.00",15`}, + } + manager, id := newStartedTestManager(t, client) + result, err := manager.USSD(context.Background(), id, "*#06#") + if err != nil { + t.Fatalf("USSD: %v", err) + } + if result.Status != "final" || result.Continueable || result.SessionID != "" { + t.Fatalf("final result = %+v, want no session", result) + } + client.assertDone(t) +} diff --git a/internal/device/sms.go b/internal/device/sms.go new file mode 100644 index 0000000..876fbbd --- /dev/null +++ b/internal/device/sms.go @@ -0,0 +1,444 @@ +package device + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "vocat/internal/modem" +) + +func (manager *Manager) SendSMS( + ctx context.Context, + id string, + recipient string, + text string, +) (SMSSendResult, error) { + parts, err := prepareSMSParts(recipient, text) + if err != nil { + return SMSSendResult{}, err + } + result := SMSSendResult{ + To: parts[0].to, + Encoding: parts[0].encoding, + DeliveryConfirmed: false, + DeliveryStatus: "unknown", + SubmissionStatus: "unknown", + SubmittedAt: time.Now().UTC(), + PartsTotal: len(parts), + PartResults: make([]SMSPartResult, 0, len(parts)), + } + if parts[0].concatReference != nil { + reference := *parts[0].concatReference + result.ConcatReference = &reference + } + state, err := manager.lookup(id) + if err != nil { + return result, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return result, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return result, err + } + if err := manager.regionBlockError(state); err != nil { + result.SubmissionStatus = "region_blocked" + manager.setResult(id, state, nil, err) + return result, err + } + for _, command := range parts[0].setup { + if _, err := manager.command(ctx, client, command); err != nil { + result.SubmissionStatus = "setup_failed" + manager.setResult(id, state, nil, err) + return result, err + } + } + + for _, part := range parts { + response, submitErr := manager.prompt( + ctx, + client, + part.prompt, + part.payload, + ) + reference, found := parseCMGSReference(response) + partResult := SMSPartResult{ + Part: part.part, + Total: part.total, + MessageReference: reference, + ReferenceKnown: found, + AcceptedByModem: response.OK() && found, + SubmissionStatus: "unknown", + ModemFinal: response.Final, + ModemEvidence: append([]string(nil), response.Lines...), + SubmittedAt: time.Now().UTC(), + } + switch { + case submitErr != nil && found: + partResult.SubmissionStatus = "reference_returned_without_final" + case submitErr != nil: + var commandErr *modem.CommandError + if errors.As(submitErr, &commandErr) { + partResult.SubmissionStatus = "rejected_by_modem" + } + case !found: + partResult.SubmissionStatus = "unconfirmed_without_reference" + default: + partResult.SubmissionStatus = "accepted_by_modem" + } + result.PartResults = append(result.PartResults, partResult) + result.PartsAttempted++ + if partResult.AcceptedByModem { + result.PartsAccepted++ + } + result.ModemFinal = response.Final + if len(parts) == 1 { + result.MessageReference = reference + result.ReferenceKnown = found + result.AcceptedByModem = partResult.AcceptedByModem + result.ModemEvidence = append([]string(nil), response.Lines...) + } else { + for _, line := range response.Lines { + result.ModemEvidence = append( + result.ModemEvidence, + fmt.Sprintf("part %d/%d: %s", part.part, part.total, line), + ) + } + } + + if submitErr == nil && !found { + submitErr = ErrSMSReferenceMissing + } + if submitErr == nil { + continue + } + var commandErr *modem.CommandError + switch { + case len(parts) == 1: + result.SubmissionStatus = partResult.SubmissionStatus + case result.PartsAccepted > 0: + result.SubmissionStatus = "partially_accepted_by_modem" + case errors.As(submitErr, &commandErr): + result.SubmissionStatus = "rejected_by_modem" + default: + result.SubmissionStatus = "unknown" + } + if errors.Is(submitErr, modem.ErrCommandTimeout) || + errors.Is(submitErr, context.Canceled) || + errors.Is(submitErr, context.DeadlineExceeded) { + // A timeout after Ctrl-Z has an inherently uncertain outcome. Close + // this session so a late +CMGS/OK cannot corrupt the next command; + // callers must decide whether it is safe to retry. + _ = client.Close() + state.client = nil + } + partErr := fmt.Errorf( + "submit SMS part %d/%d: %w", + part.part, + part.total, + submitErr, + ) + manager.setResult(id, state, nil, partErr) + return result, partErr + } + + result.AcceptedByModem = true + result.AllPartsAccepted = true + result.SubmissionStatus = "accepted_by_modem" + manager.setResult(id, state, nil, nil) + return result, nil +} + +// smsListStorages are the message storage areas ListSMS enumerates. AT+CMGL +// only reads the single storage currently selected by CPMS mem1, so a message +// that landed in SIM storage (SM) is invisible while the module memory (ME) is +// selected, and vice versa. Reading both guarantees nothing is missed regardless +// of where the network or SIM placed it. MT is the union of SM and ME, so it is +// not listed separately. +var smsListStorages = []string{"SM", "ME"} + +func (manager *Manager) ListSMS( + ctx context.Context, + id string, +) ([]SMSMessage, error) { + state, err := manager.lookup(id) + if err != nil { + return nil, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return nil, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return nil, err + } + if _, err := manager.command(ctx, client, "AT+CMGF=0"); err != nil { + manager.setResult(id, state, nil, err) + return nil, err + } + var messages []SMSMessage + var lastErr error + listed := false + for _, storage := range smsListStorages { + // Select this storage for reading (mem1 only, so send/receive routing is + // untouched). An unsupported storage reports an error; skip it rather than + // fail the whole listing. + if _, err := manager.command( + ctx, + client, + fmt.Sprintf("AT+CPMS=%q", storage), + ); err != nil { + lastErr = err + continue + } + response, err := manager.command(ctx, client, "AT+CMGL=4") + if err != nil { + lastErr = err + continue + } + listed = true + for _, message := range parseCMGL(response) { + message.Storage = storage + messages = append(messages, message) + } + } + if !listed && lastErr != nil { + manager.setResult(id, state, nil, lastErr) + return nil, lastErr + } + manager.setResult(id, state, nil, nil) + return messages, nil +} + +func (manager *Manager) ReadSMS( + ctx context.Context, + id string, + index int, +) (SMSMessage, error) { + if index <= 0 { + return SMSMessage{}, ErrSMSInvalidMessageIndex + } + state, err := manager.lookup(id) + if err != nil { + return SMSMessage{}, err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return SMSMessage{}, err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return SMSMessage{}, err + } + if _, err := manager.command(ctx, client, "AT+CMGF=0"); err != nil { + manager.setResult(id, state, nil, err) + return SMSMessage{}, err + } + response, err := manager.command( + ctx, + client, + fmt.Sprintf("AT+CMGR=%d", index), + ) + if err != nil { + manager.setResult(id, state, nil, err) + return SMSMessage{}, err + } + message, err := parseCMGR(index, response) + manager.setResult(id, state, nil, err) + return message, err +} + +func (manager *Manager) DeleteSMS( + ctx context.Context, + id string, + index int, +) error { + if index <= 0 { + return ErrSMSInvalidMessageIndex + } + state, err := manager.lookup(id) + if err != nil { + return err + } + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return err + } + client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) + if err != nil { + manager.setResult(id, state, nil, err) + return err + } + _, err = manager.command(ctx, client, fmt.Sprintf("AT+CMGD=%d", index)) + manager.setResult(id, state, nil, err) + return err +} + +func (manager *Manager) prompt( + ctx context.Context, + client modem.Client, + command string, + payload []byte, +) (modem.Response, error) { + promptClient, ok := client.(modem.PromptClient) + if !ok { + return modem.Response{}, ErrSMSPromptUnsupported + } + commandCtx, cancel := manager.withTimeout(ctx, manager.smsTimeout) + defer cancel() + response, err := promptClient.ExecutePrompt(commandCtx, command, payload) + if err != nil { + return response, fmt.Errorf("%s: %w", command, err) + } + return response, nil +} + +func parseCMGSReference(response modem.Response) (int, bool) { + value := valueAfterPrefix(response, "+CMGS:") + values := csvValues(value) + if len(values) == 0 { + return 0, false + } + reference, err := strconv.Atoi(strings.TrimSpace(values[0])) + if err != nil || reference < 0 || reference > 255 { + return 0, false + } + return reference, true +} + +type smsRecordHeader struct { + index int + status SMSStorageStatus + modemLength int + err error +} + +func parseCMGL(response modem.Response) []SMSMessage { + var result []SMSMessage + for index := 0; index < len(response.Lines); { + line := strings.TrimSpace(response.Lines[index]) + if !strings.HasPrefix(strings.ToUpper(line), "+CMGL:") { + index++ + continue + } + header := parseCMGLHeader(line) + index++ + rawPDU := "" + if index < len(response.Lines) && + !strings.HasPrefix( + strings.ToUpper(strings.TrimSpace(response.Lines[index])), + "+CMGL:", + ) { + rawPDU = strings.TrimSpace(response.Lines[index]) + index++ + } + message := SMSMessage{ + Index: header.index, + StorageStatus: header.status, + ModemLength: header.modemLength, + Direction: SMSDirectionUnknown, + Encoding: SMSEncodingUnknown, + RawPDU: strings.ToUpper(rawPDU), + } + switch { + case header.err != nil: + message.DecodeError = header.err.Error() + case rawPDU == "": + message.DecodeError = "CMGL record has no PDU" + default: + decoded, decodeErr := decodeSMSPDU(rawPDU) + decoded.Index = header.index + decoded.StorageStatus = header.status + decoded.ModemLength = header.modemLength + message = decoded + if decodeErr != nil { + message.DecodeError = decodeErr.Error() + } + } + result = append(result, message) + } + return result +} + +func parseCMGLHeader(line string) smsRecordHeader { + value := strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + values := csvValues(value) + header := smsRecordHeader{status: SMSStatusUnknown} + if len(values) < 2 { + header.err = errors.New("invalid CMGL header") + return header + } + var ok bool + header.index, ok = parseDecimal(values[0]) + if !ok || header.index <= 0 { + header.err = errors.New("invalid CMGL message index") + } + header.status = parseSMSStorageStatus(values[1]) + if len(values) >= 3 { + if length, found := parseDecimal(values[len(values)-1]); found { + header.modemLength = length + } + } + return header +} + +func parseCMGR(index int, response modem.Response) (SMSMessage, error) { + for lineIndex, line := range response.Lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToUpper(line), "+CMGR:") { + continue + } + values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1])) + if len(values) < 1 { + return SMSMessage{}, errors.New("invalid CMGR header") + } + status := parseSMSStorageStatus(values[0]) + modemLength := 0 + if length, found := parseDecimal(values[len(values)-1]); found { + modemLength = length + } + if lineIndex+1 >= len(response.Lines) { + return SMSMessage{}, errors.New("CMGR response has no PDU") + } + message, decodeErr := decodeSMSPDU(response.Lines[lineIndex+1]) + message.Index = index + message.StorageStatus = status + message.ModemLength = modemLength + if decodeErr != nil { + message.DecodeError = decodeErr.Error() + } + // Return the raw record even when decoding is incomplete. + return message, nil + } + return SMSMessage{}, errors.New("modem did not return a CMGR record") +} + +func parseSMSStorageStatus(value string) SMSStorageStatus { + value = strings.ToUpper(strings.Trim(strings.TrimSpace(value), `"`)) + switch value { + case "0", "REC UNREAD": + return SMSStatusReceivedUnread + case "1", "REC READ": + return SMSStatusReceivedRead + case "2", "STO UNSENT": + return SMSStatusStoredUnsent + case "3", "STO SENT": + return SMSStatusStoredSent + default: + return SMSStatusUnknown + } +} diff --git a/internal/device/sms_pdu.go b/internal/device/sms_pdu.go new file mode 100644 index 0000000..ddb3db5 --- /dev/null +++ b/internal/device/sms_pdu.go @@ -0,0 +1,944 @@ +package device + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "strconv" + "strings" + "time" + "unicode/utf16" +) + +var gsm7DefaultAlphabet = [128]rune{ + '@', '£', '$', '¥', 'è', 'é', 'ù', 'ì', + 'ò', 'Ç', '\n', 'Ø', 'ø', '\r', 'Å', 'å', + 'Δ', '_', 'Φ', 'Γ', 'Λ', 'Ω', 'Π', 'Ψ', + 'Σ', 'Θ', 'Ξ', '\x1b', 'Æ', 'æ', 'ß', 'É', + ' ', '!', '"', '#', '¤', '%', '&', '\'', + '(', ')', '*', '+', ',', '-', '.', '/', + '0', '1', '2', '3', '4', '5', '6', '7', + '8', '9', ':', ';', '<', '=', '>', '?', + '¡', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', + 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', + 'X', 'Y', 'Z', 'Ä', 'Ö', 'Ñ', 'Ü', '§', + '¿', 'a', 'b', 'c', 'd', 'e', 'f', 'g', + 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', + 'x', 'y', 'z', 'ä', 'ö', 'ñ', 'ü', 'à', +} + +var gsm7ExtensionAlphabet = map[byte]rune{ + 0x0a: '\f', + 0x14: '^', + 0x28: '{', + 0x29: '}', + 0x2f: '\\', + 0x3c: '[', + 0x3d: '~', + 0x3e: ']', + 0x40: '|', + 0x65: '€', +} + +var gsm7Encoder = func() map[rune][]byte { + result := make(map[rune][]byte, len(gsm7DefaultAlphabet)+len(gsm7ExtensionAlphabet)) + for code, character := range gsm7DefaultAlphabet { + if code == 0x1b { + continue + } + result[character] = []byte{byte(code)} + } + for code, character := range gsm7ExtensionAlphabet { + result[character] = []byte{0x1b, code} + } + return result +}() + +type preparedSMS struct { + to string + encoding SMSEncoding + payload []byte + prompt string + setup []string + tpduLength int + part int + total int + concatReference *int +} + +// PrepareSMSSubmitTPDUs applies the same recipient validation, alphabet +// selection and concatenation rules as SendSMS, but returns raw SMS-SUBMIT +// TPDUs suitable for a non-AT transport such as 3GPP SMS over IMS. +func PrepareSMSSubmitTPDUs(recipient, text string) ([]SMSSubmitTPDU, error) { + parts, err := prepareSMSParts(recipient, text) + if err != nil { + return nil, err + } + result := make([]SMSSubmitTPDU, 0, len(parts)) + for _, part := range parts { + pduHex := string(part.payload) + encoding := part.encoding + if part.encoding == SMSEncodingGSM7Text { + septets, ok := encodeGSM7(text) + if !ok { + return nil, errors.New("SMS text could not be encoded as GSM-7") + } + pduHex, _, err = encodeSubmitPDU(part.to, septets, nil) + if err != nil { + return nil, err + } + encoding = SMSEncodingGSM7PDU + } + pdu, err := hex.DecodeString(pduHex) + if err != nil || len(pdu) < 2 { + return nil, errors.New("encoded SMS PDU is invalid") + } + smscBytes := int(pdu[0]) + if 1+smscBytes >= len(pdu) { + return nil, errors.New("encoded SMS PDU has no TPDU") + } + result = append(result, SMSSubmitTPDU{ + To: part.to, + Encoding: encoding, + TPDU: append([]byte(nil), pdu[1+smscBytes:]...), + Part: part.part, + Total: part.total, + ConcatReference: part.concatReference, + }) + } + return result, nil +} + +// DecodeSMSDeliverTPDU decodes a TPDU carried by RP-DATA. The existing modem +// decoder expects the AT PDU-mode SMSC prefix, so an empty SMSC is prepended. +func DecodeSMSDeliverTPDU(tpdu []byte) (SMSMessage, error) { + if len(tpdu) == 0 { + return SMSMessage{}, errors.New("SMS TPDU is empty") + } + pdu := make([]byte, 1, len(tpdu)+1) + pdu = append(pdu, tpdu...) + return decodeSMSPDU(hex.EncodeToString(pdu)) +} + +func prepareSMS(recipient, text string) (preparedSMS, error) { + parts, err := prepareSMSPartsWithReference(recipient, text, 0) + if err != nil { + return preparedSMS{}, err + } + if len(parts) != 1 { + return preparedSMS{}, fmt.Errorf( + "%w: message requires %d parts", + ErrSMSTooLong, + len(parts), + ) + } + return parts[0], nil +} + +func prepareSMSParts(recipient, text string) ([]preparedSMS, error) { + parts, err := prepareSMSPartsWithReference(recipient, text, 0) + if err != nil || len(parts) <= 1 { + return parts, err + } + var reference [1]byte + if _, err := rand.Read(reference[:]); err != nil { + return nil, fmt.Errorf("generate SMS concatenation reference: %w", err) + } + return prepareSMSPartsWithReference(recipient, text, reference[0]) +} + +func prepareSMSPartsWithReference( + recipient string, + text string, + reference byte, +) ([]preparedSMS, error) { + recipient, err := normalizeSMSRecipient(recipient) + if err != nil { + return nil, err + } + if text == "" { + return nil, ErrSMSEmpty + } + if strings.IndexByte(text, 0) >= 0 { + return nil, errors.New("SMS text contains NUL") + } + + septets, gsm7 := encodeGSM7(text) + if gsm7 && len(septets) <= 160 && canSendDirectGSM7(text) { + return []preparedSMS{{ + to: recipient, + encoding: SMSEncodingGSM7Text, + payload: []byte(text), + prompt: fmt.Sprintf(`AT+CMGS="%s"`, recipient), + setup: []string{ + "AT+CMGF=1", + `AT+CSCS="GSM"`, + // TP-SRR requests a network delivery status report. + "AT+CSMP=49,167,0,0", + }, + part: 1, + total: 1, + }}, nil + } + if gsm7 && len(septets) <= 160 { + pdu, tpduLength, err := encodeSubmitPDU(recipient, septets, nil) + if err != nil { + return nil, err + } + return []preparedSMS{{ + to: recipient, + encoding: SMSEncodingGSM7PDU, + payload: []byte(pdu), + prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), + setup: []string{"AT+CMGF=0"}, + tpduLength: tpduLength, + part: 1, + total: 1, + }}, nil + } + if gsm7 { + segments := splitGSM7(septets, 153) + if len(segments) > 255 { + return nil, fmt.Errorf( + "%w: GSM-7 requires %d concatenated parts; maximum is 255", + ErrSMSTooLong, + len(segments), + ) + } + referenceValue := int(reference) + result := make([]preparedSMS, 0, len(segments)) + for index, segment := range segments { + header := concatUDH(reference, len(segments), index+1) + pdu, tpduLength, encodeErr := encodeSubmitPDUWithHeader( + recipient, + segment, + nil, + header, + ) + if encodeErr != nil { + return nil, encodeErr + } + result = append(result, preparedSMS{ + to: recipient, + encoding: SMSEncodingGSM7PDU, + payload: []byte(pdu), + prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), + setup: []string{"AT+CMGF=0"}, + tpduLength: tpduLength, + part: index + 1, + total: len(segments), + concatReference: &referenceValue, + }) + } + return result, nil + } + + units := utf16.Encode([]rune(text)) + if len(units) <= 70 { + ucs2 := encodeUCS2Units(units) + pdu, tpduLength, err := encodeSubmitPDU(recipient, nil, ucs2) + if err != nil { + return nil, err + } + return []preparedSMS{{ + to: recipient, + encoding: SMSEncodingUCS2PDU, + payload: []byte(pdu), + prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), + setup: []string{"AT+CMGF=0"}, + tpduLength: tpduLength, + part: 1, + total: 1, + }}, nil + } + segments := splitUCS2(units, 67) + if len(segments) > 255 { + return nil, fmt.Errorf( + "%w: UCS2 requires %d concatenated parts; maximum is 255", + ErrSMSTooLong, + len(segments), + ) + } + referenceValue := int(reference) + result := make([]preparedSMS, 0, len(segments)) + for index, segment := range segments { + header := concatUDH(reference, len(segments), index+1) + pdu, tpduLength, encodeErr := encodeSubmitPDUWithHeader( + recipient, + nil, + encodeUCS2Units(segment), + header, + ) + if encodeErr != nil { + return nil, encodeErr + } + result = append(result, preparedSMS{ + to: recipient, + encoding: SMSEncodingUCS2PDU, + payload: []byte(pdu), + prompt: fmt.Sprintf("AT+CMGS=%d", tpduLength), + setup: []string{"AT+CMGF=0"}, + tpduLength: tpduLength, + part: index + 1, + total: len(segments), + concatReference: &referenceValue, + }) + } + return result, nil +} + +func splitGSM7(septets []byte, maximum int) [][]byte { + var result [][]byte + for len(septets) > 0 { + count := maximum + if count > len(septets) { + count = len(septets) + } + if count < len(septets) && septets[count-1] == 0x1b { + count-- + } + result = append(result, append([]byte(nil), septets[:count]...)) + septets = septets[count:] + } + return result +} + +func splitUCS2(units []uint16, maximum int) [][]uint16 { + var result [][]uint16 + for len(units) > 0 { + count := maximum + if count > len(units) { + count = len(units) + } + if count < len(units) && + utf16.IsSurrogate(rune(units[count-1])) && + units[count-1] >= 0xd800 && units[count-1] <= 0xdbff && + units[count] >= 0xdc00 && units[count] <= 0xdfff { + count-- + } + result = append(result, append([]uint16(nil), units[:count]...)) + units = units[count:] + } + return result +} + +func encodeUCS2Units(units []uint16) []byte { + result := make([]byte, 0, len(units)*2) + for _, unit := range units { + result = append(result, byte(unit>>8), byte(unit)) + } + return result +} + +func concatUDH(reference byte, total, sequence int) []byte { + return []byte{0x05, 0x00, 0x03, reference, byte(total), byte(sequence)} +} + +func normalizeSMSRecipient(value string) (string, error) { + var result strings.Builder + for _, character := range strings.TrimSpace(value) { + switch { + case character >= '0' && character <= '9': + result.WriteRune(character) + case character == '+' && result.Len() == 0: + result.WriteRune(character) + case character == ' ' || character == '-' || + character == '(' || character == ')': + default: + return "", ErrSMSInvalidRecipient + } + } + normalized := result.String() + digits := strings.TrimPrefix(normalized, "+") + if len(digits) < 1 || len(digits) > 20 { + return "", ErrSMSInvalidRecipient + } + if strings.IndexFunc(digits, func(character rune) bool { + return character < '0' || character > '9' + }) >= 0 { + return "", ErrSMSInvalidRecipient + } + return normalized, nil +} + +func encodeGSM7(text string) ([]byte, bool) { + result := make([]byte, 0, len(text)) + for _, character := range text { + encoded, ok := gsm7Encoder[character] + if !ok { + return nil, false + } + result = append(result, encoded...) + } + return result, true +} + +func canSendDirectGSM7(text string) bool { + for _, character := range text { + encoded, ok := gsm7Encoder[character] + if !ok || len(encoded) != 1 || encoded[0] == 0x1b || + character > 0x7f || byte(character) != encoded[0] { + return false + } + } + return true +} + +func encodeSubmitPDU( + recipient string, + gsm7Septets []byte, + ucs2 []byte, +) (string, int, error) { + return encodeSubmitPDUWithHeader(recipient, gsm7Septets, ucs2, nil) +} + +func encodeSubmitPDUWithHeader( + recipient string, + gsm7Septets []byte, + ucs2 []byte, + userDataHeader []byte, +) (string, int, error) { + digits := strings.TrimPrefix(recipient, "+") + address, err := encodeSemiOctets(digits) + if err != nil { + return "", 0, err + } + toa := byte(0x81) + if strings.HasPrefix(recipient, "+") { + toa = 0x91 + } + + // SMS-SUBMIT with TP-SRR set so both AT PDU mode and SMS over IMS can + // receive an SMS-STATUS-REPORT for this submission. + firstOctet := byte(0x21) + if len(userDataHeader) > 0 { + if int(userDataHeader[0])+1 != len(userDataHeader) { + return "", 0, errors.New("invalid SMS concatenation header") + } + firstOctet |= 0x40 + } + pdu := []byte{ + 0x00, // Use the SMSC configured on the SIM. + firstOctet, + 0x00, // TP-MR allocated by the modem/network. + byte(len(digits)), + toa, + } + pdu = append(pdu, address...) + pdu = append(pdu, 0x00) // TP-PID + switch { + case gsm7Septets != nil: + headerSeptets := 0 + startBit := 0 + if len(userDataHeader) > 0 { + headerSeptets = (len(userDataHeader)*8 + 6) / 7 + startBit = headerSeptets * 7 + } + userDataLength := headerSeptets + len(gsm7Septets) + if userDataLength > 160 { + return "", 0, ErrSMSTooLong + } + packed := packSeptets(gsm7Septets, startBit) + copy(packed, userDataHeader) + pdu = append(pdu, 0x00, byte(userDataLength)) + pdu = append(pdu, packed...) + case ucs2 != nil: + userDataLength := len(userDataHeader) + len(ucs2) + if userDataLength > 140 { + return "", 0, ErrSMSTooLong + } + pdu = append(pdu, 0x08, byte(userDataLength)) + pdu = append(pdu, userDataHeader...) + pdu = append(pdu, ucs2...) + default: + return "", 0, errors.New("SMS PDU has no user data") + } + return strings.ToUpper(hex.EncodeToString(pdu)), len(pdu) - 1, nil +} + +func encodeSemiOctets(digits string) ([]byte, error) { + if digits == "" { + return nil, ErrSMSInvalidRecipient + } + result := make([]byte, (len(digits)+1)/2) + for index := 0; index < len(digits); index += 2 { + low := digits[index] + if low < '0' || low > '9' { + return nil, ErrSMSInvalidRecipient + } + high := byte('F') + if index+1 < len(digits) { + high = digits[index+1] + if high < '0' || high > '9' { + return nil, ErrSMSInvalidRecipient + } + } + highNibble := byte(0x0f) + if high != 'F' { + highNibble = high - '0' + } + result[index/2] = (highNibble << 4) | (low - '0') + } + return result, nil +} + +func packSeptets(septets []byte, startBit int) []byte { + if len(septets) == 0 { + return nil + } + bitLength := startBit + len(septets)*7 + result := make([]byte, (bitLength+7)/8) + for index, septet := range septets { + bit := startBit + index*7 + byteIndex := bit / 8 + shift := uint(bit % 8) + result[byteIndex] |= (septet & 0x7f) << shift + if shift > 1 && byteIndex+1 < len(result) { + result[byteIndex+1] |= (septet & 0x7f) >> (8 - shift) + } + } + return result +} + +func unpackSeptets(data []byte, count, startBit int) ([]byte, error) { + if count < 0 || startBit < 0 || startBit+count*7 > len(data)*8 { + return nil, errors.New("GSM-7 user data is truncated") + } + result := make([]byte, count) + for index := 0; index < count; index++ { + bit := startBit + index*7 + byteIndex := bit / 8 + shift := uint(bit % 8) + value := data[byteIndex] >> shift + if shift > 1 && byteIndex+1 < len(data) { + value |= data[byteIndex+1] << (8 - shift) + } + result[index] = value & 0x7f + } + return result, nil +} + +func decodeGSM7(septets []byte) (string, error) { + var result strings.Builder + for index := 0; index < len(septets); index++ { + code := septets[index] + if code != 0x1b { + if int(code) >= len(gsm7DefaultAlphabet) { + return result.String(), errors.New("invalid GSM-7 code") + } + result.WriteRune(gsm7DefaultAlphabet[code]) + continue + } + index++ + if index >= len(septets) { + return result.String(), errors.New("trailing GSM-7 escape") + } + character, ok := gsm7ExtensionAlphabet[septets[index]] + if !ok { + return result.String(), fmt.Errorf( + "unknown GSM-7 extension 0x%02X", + septets[index], + ) + } + result.WriteRune(character) + } + return result.String(), nil +} + +type pduCursor struct { + data []byte + index int +} + +func (cursor *pduCursor) byte() (byte, error) { + if cursor.index >= len(cursor.data) { + return 0, errors.New("SMS PDU is truncated") + } + value := cursor.data[cursor.index] + cursor.index++ + return value, nil +} + +func (cursor *pduCursor) bytes(count int) ([]byte, error) { + if count < 0 || cursor.index+count > len(cursor.data) { + return nil, errors.New("SMS PDU is truncated") + } + value := cursor.data[cursor.index : cursor.index+count] + cursor.index += count + return value, nil +} + +func decodeSMSPDU(raw string) (SMSMessage, error) { + raw = strings.ToUpper(strings.TrimSpace(raw)) + message := SMSMessage{ + Direction: SMSDirectionUnknown, + Encoding: SMSEncodingUnknown, + StorageStatus: SMSStatusUnknown, + RawPDU: raw, + } + decoded, err := hex.DecodeString(raw) + if err != nil || len(decoded) < 2 { + if err == nil { + err = errors.New("SMS PDU is too short") + } + return message, err + } + cursor := &pduCursor{data: decoded} + smscLength, _ := cursor.byte() + if int(smscLength) > len(decoded)-1 { + return message, errors.New("SMS PDU SMSC length exceeds payload") + } + if smscLength > 0 { + smsc, _ := cursor.bytes(int(smscLength)) + if len(smsc) > 1 { + message.ServiceCenter = decodeNumericAddress( + smsc[1:], + (len(smsc)-1)*2, + smsc[0], + ) + } + } + firstOctet, err := cursor.byte() + if err != nil { + return message, err + } + switch firstOctet & 0x03 { + case 0: + message.Direction = SMSDirectionReceived + err = decodeDeliverPDU(cursor, firstOctet, &message) + case 1: + message.Direction = SMSDirectionSubmitted + err = decodeSubmitPDU(cursor, firstOctet, &message) + case 2: + message.Direction = SMSDirectionStatusReport + err = decodeStatusReportPDU(cursor, &message) + default: + err = errors.New("unsupported SMS PDU message type") + } + return message, err +} + +func decodeDeliverPDU( + cursor *pduCursor, + firstOctet byte, + message *SMSMessage, +) error { + address, err := readTPAddress(cursor) + if err != nil { + return err + } + message.From = address + pid, err := cursor.byte() + if err != nil { + return err + } + dcs, err := cursor.byte() + if err != nil { + return err + } + message.ProtocolID = int(pid) + message.DataCodingScheme = int(dcs) + timestamp, err := cursor.bytes(7) + if err != nil { + return err + } + if parsed, timestampErr := decodeSMSTimestamp(timestamp); timestampErr == nil { + message.ServiceCenterTimestamp = parsed + } + udl, err := cursor.byte() + if err != nil { + return err + } + return decodeUserData(cursor.data[cursor.index:], firstOctet, dcs, int(udl), message) +} + +func decodeSubmitPDU( + cursor *pduCursor, + firstOctet byte, + message *SMSMessage, +) error { + reference, err := cursor.byte() + if err != nil { + return err + } + message.MessageReference = intPointer(int(reference)) + address, err := readTPAddress(cursor) + if err != nil { + return err + } + message.To = address + pid, err := cursor.byte() + if err != nil { + return err + } + dcs, err := cursor.byte() + if err != nil { + return err + } + message.ProtocolID = int(pid) + message.DataCodingScheme = int(dcs) + switch (firstOctet >> 3) & 0x03 { + case 2: + if _, err := cursor.byte(); err != nil { + return err + } + case 1, 3: + if _, err := cursor.bytes(7); err != nil { + return err + } + } + udl, err := cursor.byte() + if err != nil { + return err + } + return decodeUserData(cursor.data[cursor.index:], firstOctet, dcs, int(udl), message) +} + +func decodeStatusReportPDU( + cursor *pduCursor, + message *SMSMessage, +) error { + reference, err := cursor.byte() + if err != nil { + return err + } + message.MessageReference = intPointer(int(reference)) + address, err := readTPAddress(cursor) + if err != nil { + return err + } + message.To = address + scts, err := cursor.bytes(7) + if err != nil { + return err + } + discharge, err := cursor.bytes(7) + if err != nil { + return err + } + status, err := cursor.byte() + if err != nil { + return err + } + message.StatusCode = intPointer(int(status)) + message.DeliveryStatus = smsDeliveryStatus(status) + if parsed, parseErr := decodeSMSTimestamp(scts); parseErr == nil { + message.ServiceCenterTimestamp = parsed + } + if parsed, parseErr := decodeSMSTimestamp(discharge); parseErr == nil { + message.DischargeTimestamp = parsed + } + return nil +} + +func smsDeliveryStatus(status byte) string { + switch { + case status <= 0x1f: + return "delivered" + case status <= 0x3f: + return "temporary_error" + case status <= 0x5f: + return "permanent_error" + case status <= 0x7f: + return "temporary_error_no_retry" + default: + return "reserved" + } +} + +func readTPAddress(cursor *pduCursor) (string, error) { + length, err := cursor.byte() + if err != nil { + return "", err + } + toa, err := cursor.byte() + if err != nil { + return "", err + } + byteCount := (int(length) + 1) / 2 + value, err := cursor.bytes(byteCount) + if err != nil { + return "", err + } + if toa&0x70 == 0x50 { + septetCount := int(length) * 4 / 7 + septets, unpackErr := unpackSeptets(value, septetCount, 0) + if unpackErr != nil { + return "", unpackErr + } + return decodeGSM7(septets) + } + return decodeNumericAddress(value, int(length), toa), nil +} + +func decodeNumericAddress(value []byte, digits int, toa byte) string { + var result strings.Builder + if toa&0x70 == 0x10 { + result.WriteByte('+') + } + written := 0 + for _, octet := range value { + for _, digit := range []byte{octet & 0x0f, octet >> 4} { + if written >= digits { + return result.String() + } + if digit <= 9 { + result.WriteByte('0' + digit) + written++ + } + } + } + return result.String() +} + +func decodeUserData( + data []byte, + firstOctet byte, + dcs byte, + udl int, + message *SMSMessage, +) error { + alphabet := dcs & 0x0c + expectedBytes := udl + if alphabet == 0 { + expectedBytes = (udl*7 + 7) / 8 + } + if expectedBytes > len(data) { + return errors.New("SMS user data is truncated") + } + data = data[:expectedBytes] + message.RawUserData = strings.ToUpper(hex.EncodeToString(data)) + + headerBytes := 0 + if firstOctet&0x40 != 0 { + if len(data) == 0 { + return errors.New("SMS has UDHI but no user data header") + } + headerBytes = int(data[0]) + 1 + if headerBytes > len(data) { + return errors.New("SMS user data header is truncated") + } + message.Concat = parseConcatHeader(data[1:headerBytes]) + } + + switch alphabet { + case 0: + message.Encoding = SMSEncodingGSM7PDU + headerSeptets := 0 + if headerBytes > 0 { + headerSeptets = (headerBytes*8 + 6) / 7 + } + textSeptets := udl - headerSeptets + septets, err := unpackSeptets(data, textSeptets, headerSeptets*7) + if err != nil { + return err + } + text, err := decodeGSM7(septets) + message.Text = text + return err + case 8: + message.Encoding = SMSEncodingUCS2PDU + payload := data[headerBytes:] + if len(payload)%2 != 0 { + return errors.New("UCS2 SMS has an odd byte count") + } + 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 + default: + message.Encoding = SMSEncoding8BitPDU + return nil + } +} + +func parseConcatHeader(header []byte) *SMSConcatInfo { + for index := 0; index+1 < len(header); { + identifier := header[index] + length := int(header[index+1]) + index += 2 + if index+length > len(header) { + return nil + } + value := header[index : index+length] + switch { + case identifier == 0x00 && length == 3: + return &SMSConcatInfo{ + Reference: int(value[0]), + Total: int(value[1]), + Sequence: int(value[2]), + } + case identifier == 0x08 && length == 4: + return &SMSConcatInfo{ + Reference: int(value[0])<<8 | int(value[1]), + Total: int(value[2]), + Sequence: int(value[3]), + } + } + index += length + } + return nil +} + +func decodeSMSTimestamp(value []byte) (*time.Time, error) { + if len(value) != 7 { + return nil, errors.New("SMS timestamp must be seven octets") + } + component := func(octet byte) (int, error) { + low, high := int(octet&0x0f), int(octet>>4) + if low > 9 || high > 9 { + return 0, errors.New("invalid timestamp semi-octet") + } + return low*10 + high, nil + } + parts := make([]int, 6) + for index := range parts { + parsed, err := component(value[index]) + if err != nil { + return nil, err + } + parts[index] = parsed + } + zoneByte := value[6] + negative := zoneByte&0x08 != 0 + zoneByte &^= 0x08 + quarters, err := component(zoneByte) + if err != nil || quarters > 56 { + return nil, errors.New("invalid SMS timestamp timezone") + } + offset := quarters * 15 * 60 + if negative { + offset = -offset + } + year := 2000 + parts[0] + if parts[0] >= 90 { + year = 1900 + parts[0] + } + if parts[1] < 1 || parts[1] > 12 || + parts[2] < 1 || parts[2] > 31 || + parts[3] > 23 || parts[4] > 59 || parts[5] > 60 { + return nil, errors.New("invalid SMS timestamp component") + } + zone := time.FixedZone("SMS", offset) + result := time.Date( + year, + time.Month(parts[1]), + parts[2], + parts[3], + parts[4], + parts[5], + 0, + zone, + ) + return &result, nil +} + +func parseDecimal(value string) (int, bool) { + number, err := strconv.Atoi(strings.Trim(strings.TrimSpace(value), `"`)) + return number, err == nil +} diff --git a/internal/device/sms_pdu_test.go b/internal/device/sms_pdu_test.go new file mode 100644 index 0000000..405f17e --- /dev/null +++ b/internal/device/sms_pdu_test.go @@ -0,0 +1,265 @@ +package device + +import ( + "errors" + "strings" + "testing" +) + +func TestPrepareSMSSelectsDirectGSM7AndPDUEncodings(t *testing.T) { + direct, err := prepareSMS("+12 345", "HELLO") + if err != nil { + t.Fatalf("prepare direct GSM-7: %v", err) + } + if direct.to != "+12345" || + direct.encoding != SMSEncodingGSM7Text || + direct.prompt != `AT+CMGS="+12345"` || + string(direct.payload) != "HELLO" { + t.Fatalf("direct = %#v", direct) + } + + gsmPDU, err := prepareSMS("+12345", "@") + if err != nil { + t.Fatalf("prepare GSM-7 PDU: %v", err) + } + if gsmPDU.encoding != SMSEncodingGSM7PDU || + gsmPDU.tpduLength != 11 || + string(gsmPDU.payload) != "00210005912143F500000100" { + t.Fatalf("GSM PDU = %#v", gsmPDU) + } + + unicode, err := prepareSMS("+12345", "你好") + if err != nil { + t.Fatalf("prepare UCS2 PDU: %v", err) + } + if unicode.encoding != SMSEncodingUCS2PDU || + unicode.tpduLength != 14 || + unicode.prompt != "AT+CMGS=14" || + string(unicode.payload) != "00210005912143F50008044F60597D" { + t.Fatalf("UCS2 PDU = %#v", unicode) + } +} + +func TestPrepareAndDecodeTransportIndependentTPDU(t *testing.T) { + parts, err := PrepareSMSSubmitTPDUs("+12345", "HELLO") + if err != nil { + t.Fatalf("PrepareSMSSubmitTPDUs: %v", err) + } + if len(parts) != 1 || parts[0].To != "+12345" || len(parts[0].TPDU) == 0 || + parts[0].TPDU[0]&0x03 != 1 || parts[0].TPDU[0]&0x20 == 0 { + t.Fatalf("parts = %#v", parts) + } + message, err := DecodeSMSDeliverTPDU([]byte{ + 0x04, 0x05, 0x91, 0x21, 0x43, 0xf5, 0x00, 0x00, + 0x42, 0x10, 0x20, 0x30, 0x40, 0x50, 0x00, 0x05, + 0xc8, 0x22, 0x93, 0xf9, 0x04, + }) + if err != nil || message.From != "+12345" || message.Text != "HELLO" { + t.Fatalf("DecodeSMSDeliverTPDU = (%#v, %v)", message, err) + } +} + +func TestPrepareSMSRejectsInvalidAndOversizeMessages(t *testing.T) { + if _, err := prepareSMS(`12"34`, "hello"); !errors.Is(err, ErrSMSInvalidRecipient) { + t.Fatalf("invalid recipient error = %v", err) + } + if _, err := prepareSMS("12345", ""); !errors.Is(err, ErrSMSEmpty) { + t.Fatalf("empty message error = %v", err) + } + if _, err := prepareSMS( + "12345", + strings.Repeat("A", 161), + ); !errors.Is(err, ErrSMSTooLong) { + t.Fatalf("long GSM-7 error = %v", err) + } + if _, err := prepareSMS( + "12345", + strings.Repeat("你", 71), + ); !errors.Is(err, ErrSMSTooLong) { + t.Fatalf("long UCS2 error = %v", err) + } +} + +func TestPrepareMultipartGSM7Uses153SeptetsAndSharedUDH(t *testing.T) { + const reference = 0x7a + text := strings.Repeat("A", 161) + parts, err := prepareSMSPartsWithReference("+12345", text, reference) + if err != nil { + t.Fatalf("prepare multipart GSM-7: %v", err) + } + if len(parts) != 2 { + t.Fatalf("parts = %d, want 2", len(parts)) + } + for index, part := range parts { + message, decodeErr := decodeSMSPDU(string(part.payload)) + if decodeErr != nil { + t.Fatalf("decode part %d: %v", index+1, decodeErr) + } + wantText := strings.Repeat("A", 153) + if index == 1 { + wantText = strings.Repeat("A", 8) + } + if message.Text != wantText || + message.Concat == nil || + message.Concat.Reference != reference || + message.Concat.Total != 2 || + message.Concat.Sequence != index+1 || + part.part != index+1 || + part.total != 2 || + part.encoding != SMSEncodingGSM7PDU { + t.Fatalf("part %d = prepared %#v, decoded %#v", index+1, part, message) + } + } +} + +func TestPrepareMultipartGSM7DoesNotSplitExtensionEscape(t *testing.T) { + text := strings.Repeat("A", 152) + "^" + strings.Repeat("B", 10) + parts, err := prepareSMSPartsWithReference("+12345", text, 7) + if err != nil { + t.Fatalf("prepare multipart extension: %v", err) + } + if len(parts) != 2 { + t.Fatalf("parts = %d, want 2", len(parts)) + } + first, err := decodeSMSPDU(string(parts[0].payload)) + if err != nil { + t.Fatalf("decode first part: %v", err) + } + second, err := decodeSMSPDU(string(parts[1].payload)) + if err != nil { + t.Fatalf("decode second part: %v", err) + } + if first.Text != strings.Repeat("A", 152) || + second.Text != "^"+strings.Repeat("B", 10) { + t.Fatalf("split text = %q + %q", first.Text, second.Text) + } +} + +func TestPrepareMultipartUCS2DoesNotSplitSurrogatePair(t *testing.T) { + const reference = 0x52 + text := strings.Repeat("你", 66) + "😀" + strings.Repeat("好", 4) + parts, err := prepareSMSPartsWithReference("+12345", text, reference) + if err != nil { + t.Fatalf("prepare multipart UCS2: %v", err) + } + if len(parts) != 2 { + t.Fatalf("parts = %d, want 2", len(parts)) + } + first, err := decodeSMSPDU(string(parts[0].payload)) + if err != nil { + t.Fatalf("decode first part: %v", err) + } + second, err := decodeSMSPDU(string(parts[1].payload)) + if err != nil { + t.Fatalf("decode second part: %v", err) + } + if first.Text != strings.Repeat("你", 66) || + second.Text != "😀"+strings.Repeat("好", 4) { + t.Fatalf("split text = %q + %q", first.Text, second.Text) + } + for index, message := range []SMSMessage{first, second} { + if message.Concat == nil || + message.Concat.Reference != reference || + message.Concat.Total != 2 || + message.Concat.Sequence != index+1 { + t.Fatalf("concat part %d = %#v", index+1, message.Concat) + } + } +} + +func TestPrepareMultipartRejectsMoreThan255Parts(t *testing.T) { + if _, err := prepareSMSPartsWithReference( + "12345", + strings.Repeat("A", 153*255+1), + 1, + ); !errors.Is(err, ErrSMSTooLong) { + t.Fatalf("oversize multipart GSM-7 error = %v", err) + } + if _, err := prepareSMSPartsWithReference( + "12345", + strings.Repeat("你", 67*255+1), + 1, + ); !errors.Is(err, ErrSMSTooLong) { + t.Fatalf("oversize multipart UCS2 error = %v", err) + } +} + +func TestDecodeDeliverPDUHandlesGSM7AndUCS2(t *testing.T) { + gsm, err := decodeSMSPDU( + "000405912143F500004210203040500005C82293F904", + ) + if err != nil { + t.Fatalf("decode GSM-7: %v", err) + } + if gsm.Direction != SMSDirectionReceived || + gsm.From != "+12345" || + gsm.Text != "HELLO" || + gsm.Encoding != SMSEncodingGSM7PDU { + t.Fatalf("GSM message = %#v", gsm) + } + if gsm.ServiceCenterTimestamp == nil || + gsm.ServiceCenterTimestamp.Year() != 2024 || + gsm.ServiceCenterTimestamp.Month() != 1 || + gsm.ServiceCenterTimestamp.Day() != 2 || + gsm.ServiceCenterTimestamp.Hour() != 3 || + gsm.ServiceCenterTimestamp.Minute() != 4 || + gsm.ServiceCenterTimestamp.Second() != 5 { + t.Fatalf("timestamp = %v", gsm.ServiceCenterTimestamp) + } + + ucs2, err := decodeSMSPDU( + "004405912143F50008421020304050000A0500037A02014F60597D", + ) + if err != nil { + t.Fatalf("decode UCS2: %v", err) + } + if ucs2.Text != "你好" || + ucs2.Encoding != SMSEncodingUCS2PDU || + ucs2.Concat == nil || + ucs2.Concat.Reference != 0x7a || + ucs2.Concat.Total != 2 || + ucs2.Concat.Sequence != 1 { + t.Fatalf("UCS2 message = %#v", ucs2) + } +} + +func TestDecodeSubmitAndStatusReportPDU(t *testing.T) { + submit, err := decodeSMSPDU("00010005912143F500000100") + if err != nil { + t.Fatalf("decode submit: %v", err) + } + if submit.Direction != SMSDirectionSubmitted || + submit.To != "+12345" || + submit.Text != "@" { + t.Fatalf("submit = %#v", submit) + } + + report, err := decodeSMSPDU( + "00022A05912143F5421020304050004210203050500000", + ) + if err != nil { + t.Fatalf("decode status report: %v", err) + } + if report.Direction != SMSDirectionStatusReport || + report.To != "+12345" || + report.MessageReference == nil || + *report.MessageReference != 42 || + report.StatusCode == nil || + *report.StatusCode != 0 || + report.DeliveryStatus != "delivered" { + t.Fatalf("status report = %#v", report) + } +} + +func TestParseCMGLPreservesUndecodableRecord(t *testing.T) { + messages := parseCMGL(okResponse( + "+CMGL: 9,0,,4", + "NOT-A-PDU", + )) + if len(messages) != 1 || + messages[0].Index != 9 || + messages[0].RawPDU != "NOT-A-PDU" || + messages[0].DecodeError == "" { + t.Fatalf("messages = %#v", messages) + } +} diff --git a/internal/device/sms_test.go b/internal/device/sms_test.go new file mode 100644 index 0000000..b610889 --- /dev/null +++ b/internal/device/sms_test.go @@ -0,0 +1,360 @@ +package device + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + + "vocat/internal/modem" +) + +func TestManagerSendSMSDirectGSM7ReturnsAcceptanceEvidence(t *testing.T) { + client := &transcriptClient{ + steps: []clientStep{ + {command: "AT+CMGF=1", response: okResponse()}, + {command: `AT+CSCS="GSM"`, response: okResponse()}, + {command: "AT+CSMP=49,167,0,0", response: okResponse()}, + }, + promptSteps: []promptClientStep{{ + command: `AT+CMGS="+12345"`, + payload: "HELLO", + response: okResponse("+CMGS: 23"), + }}, + } + manager, id := newStartedTestManager(t, client) + + result, err := manager.SendSMS( + context.Background(), + id, + "+12 345", + "HELLO", + ) + if err != nil { + t.Fatalf("SendSMS: %v", err) + } + if result.To != "+12345" || + result.Encoding != SMSEncodingGSM7Text || + !result.AcceptedByModem || + !result.ReferenceKnown || + result.MessageReference != 23 || + result.DeliveryConfirmed || + result.DeliveryStatus != "unknown" || + result.SubmissionStatus != "accepted_by_modem" || + result.PartsTotal != 1 || + result.PartsAttempted != 1 || + result.PartsAccepted != 1 || + !result.AllPartsAccepted || + len(result.PartResults) != 1 { + t.Fatalf("result = %#v", result) + } + if len(result.ModemEvidence) != 1 || result.ModemEvidence[0] != "+CMGS: 23" { + t.Fatalf("evidence = %#v", result.ModemEvidence) + } + client.assertDone(t) +} + +func TestManagerSendMultipartSMSReturnsEveryPartReference(t *testing.T) { + var concatReference = -1 + validatePart := func(sequence int, wantText string) func(string) error { + return func(payload string) error { + message, err := decodeSMSPDU(payload) + if err != nil { + return fmt.Errorf("decode part %d: %w", sequence, err) + } + if message.Text != wantText || message.Concat == nil || + message.Concat.Total != 2 || + message.Concat.Sequence != sequence { + return fmt.Errorf("part %d decoded as %#v", sequence, message) + } + if concatReference < 0 { + concatReference = message.Concat.Reference + } else if message.Concat.Reference != concatReference { + return fmt.Errorf( + "part %d concat reference %d, want %d", + sequence, + message.Concat.Reference, + concatReference, + ) + } + return nil + } + } + client := &transcriptClient{ + steps: []clientStep{{command: "AT+CMGF=0", response: okResponse()}}, + promptSteps: []promptClientStep{ + { + command: "AT+CMGS=150", + validateBody: validatePart(1, strings.Repeat("A", 153)), + response: okResponse("+CMGS: 31"), + }, + { + command: "AT+CMGS=24", + validateBody: validatePart(2, strings.Repeat("A", 8)), + response: okResponse("+CMGS: 32"), + }, + }, + } + manager, id := newStartedTestManager(t, client) + + result, err := manager.SendSMS( + context.Background(), + id, + "+12345", + strings.Repeat("A", 161), + ) + if err != nil { + t.Fatalf("SendSMS: %v", err) + } + if result.PartsTotal != 2 || + result.PartsAttempted != 2 || + result.PartsAccepted != 2 || + !result.AcceptedByModem || + !result.AllPartsAccepted || + result.ReferenceKnown || + result.ConcatReference == nil || + *result.ConcatReference != concatReference || + len(result.PartResults) != 2 || + result.PartResults[0].MessageReference != 31 || + result.PartResults[1].MessageReference != 32 || + !result.PartResults[0].AcceptedByModem || + !result.PartResults[1].AcceptedByModem || + result.DeliveryConfirmed { + t.Fatalf("result = %#v", result) + } + client.assertDone(t) +} + +func TestManagerSendMultipartSMSStopsAndPreservesPartialEvidence(t *testing.T) { + validatePart := func(sequence int, wantText string) func(string) error { + return func(payload string) error { + message, err := decodeSMSPDU(payload) + if err != nil { + return err + } + if message.Concat == nil || + message.Concat.Sequence != sequence || + message.Text != wantText { + return fmt.Errorf("part %d decoded as %#v", sequence, message) + } + return nil + } + } + secondError := &modem.CommandError{ + Command: "AT+CMGS=24", + Final: "+CMS ERROR: 500", + } + client := &transcriptClient{ + steps: []clientStep{{command: "AT+CMGF=0", response: okResponse()}}, + promptSteps: []promptClientStep{ + { + command: "AT+CMGS=150", + validateBody: validatePart(1, strings.Repeat("A", 153)), + response: okResponse("+CMGS: 41"), + }, + { + command: "AT+CMGS=24", + validateBody: validatePart(2, strings.Repeat("A", 8)), + response: modem.Response{ + Final: "+CMS ERROR: 500", + }, + err: secondError, + }, + }, + } + manager, id := newStartedTestManager(t, client) + + result, err := manager.SendSMS( + context.Background(), + id, + "+12345", + strings.Repeat("A", 161), + ) + var commandErr *modem.CommandError + if !errors.As(err, &commandErr) { + t.Fatalf("error = %v", err) + } + if result.PartsTotal != 2 || + result.PartsAttempted != 2 || + result.PartsAccepted != 1 || + result.AcceptedByModem || + result.AllPartsAccepted || + result.SubmissionStatus != "partially_accepted_by_modem" || + len(result.PartResults) != 2 || + !result.PartResults[0].AcceptedByModem || + result.PartResults[0].MessageReference != 41 || + result.PartResults[1].AcceptedByModem || + result.PartResults[1].SubmissionStatus != "rejected_by_modem" || + result.DeliveryConfirmed { + t.Fatalf("result = %#v", result) + } + client.assertDone(t) +} + +func TestManagerSendSMSUsesUCS2PDUForChinese(t *testing.T) { + client := &transcriptClient{ + steps: []clientStep{ + {command: "AT+CMGF=0", response: okResponse()}, + }, + promptSteps: []promptClientStep{{ + command: "AT+CMGS=14", + payload: "00210005912143F50008044F60597D", + response: okResponse("+CMGS: 0"), + }}, + } + manager, id := newStartedTestManager(t, client) + + result, err := manager.SendSMS( + context.Background(), + id, + "+12345", + "你好", + ) + if err != nil { + t.Fatalf("SendSMS: %v", err) + } + if result.Encoding != SMSEncodingUCS2PDU || + !result.ReferenceKnown || + result.MessageReference != 0 || + !result.AllPartsAccepted || + result.DeliveryConfirmed { + t.Fatalf("result = %#v", result) + } + client.assertDone(t) +} + +func TestManagerSendSMSTimeoutNeverClaimsAcceptanceOrDelivery(t *testing.T) { + client := &transcriptClient{ + steps: []clientStep{ + {command: "AT+CMGF=1", response: okResponse()}, + {command: `AT+CSCS="GSM"`, response: okResponse()}, + {command: "AT+CSMP=49,167,0,0", response: okResponse()}, + }, + promptSteps: []promptClientStep{{ + command: `AT+CMGS="12345"`, + payload: "HELLO", + response: modem.Response{ + Lines: []string{"+CMGS: 77"}, + }, + err: modem.ErrCommandTimeout, + }}, + } + manager, id := newStartedTestManager(t, client) + + result, err := manager.SendSMS( + context.Background(), + id, + "12345", + "HELLO", + ) + if !errors.Is(err, modem.ErrCommandTimeout) { + t.Fatalf("error = %v", err) + } + if result.AcceptedByModem || result.DeliveryConfirmed || + !result.ReferenceKnown || result.MessageReference != 77 || + result.SubmissionStatus != "reference_returned_without_final" || + result.DeliveryStatus != "unknown" { + t.Fatalf("result = %#v", result) + } + client.mu.Lock() + closeCount := client.closeCount + client.mu.Unlock() + if closeCount != 1 { + t.Fatalf("close count = %d, want 1 after uncertain timeout", closeCount) + } + client.assertDone(t) +} + +func TestManagerListReadAndDeleteSMS(t *testing.T) { + const gsmPDU = "000405912143F500004210203040500005C82293F904" + const ucs2PDU = "000405912143F5000842102030405000044F60597D" + client := &transcriptClient{steps: []clientStep{ + {command: "AT+CMGF=0", response: okResponse()}, + {command: `AT+CPMS="SM"`, response: okResponse()}, + { + command: "AT+CMGL=4", + response: okResponse( + "+CMGL: 7,0,,23", + gsmPDU, + "+CMGL: 8,1,,22", + ucs2PDU, + ), + }, + {command: `AT+CPMS="ME"`, response: okResponse()}, + {command: "AT+CMGL=4", response: okResponse()}, + {command: "AT+CMGF=0", response: okResponse()}, + { + command: "AT+CMGR=7", + response: okResponse( + "+CMGR: 0,,23", + gsmPDU, + ), + }, + {command: "AT+CMGD=7", response: okResponse()}, + }} + manager, id := newStartedTestManager(t, client) + + messages, err := manager.ListSMS(context.Background(), id) + if err != nil { + t.Fatalf("ListSMS: %v", err) + } + if len(messages) != 2 || + messages[0].Index != 7 || + messages[0].Storage != "SM" || + messages[0].StorageStatus != SMSStatusReceivedUnread || + messages[0].Text != "HELLO" || + messages[1].Index != 8 || + messages[1].Storage != "SM" || + messages[1].StorageStatus != SMSStatusReceivedRead || + messages[1].Text != "你好" { + t.Fatalf("messages = %#v", messages) + } + + message, err := manager.ReadSMS(context.Background(), id, 7) + if err != nil { + t.Fatalf("ReadSMS: %v", err) + } + if message.Index != 7 || + message.StorageStatus != SMSStatusReceivedUnread || + message.Text != "HELLO" { + t.Fatalf("message = %#v", message) + } + if err := manager.DeleteSMS(context.Background(), id, 7); err != nil { + t.Fatalf("DeleteSMS: %v", err) + } + client.assertDone(t) +} + +func TestManagerSendSMSRequiresMessageReference(t *testing.T) { + client := &transcriptClient{ + steps: []clientStep{ + {command: "AT+CMGF=1", response: okResponse()}, + {command: `AT+CSCS="GSM"`, response: okResponse()}, + {command: "AT+CSMP=49,167,0,0", response: okResponse()}, + }, + promptSteps: []promptClientStep{{ + command: `AT+CMGS="12345"`, + payload: "HELLO", + response: okResponse(), + }}, + } + manager, id := newStartedTestManager(t, client) + + result, err := manager.SendSMS( + context.Background(), + id, + "12345", + "HELLO", + ) + if !errors.Is(err, ErrSMSReferenceMissing) { + t.Fatalf("error = %v", err) + } + if result.AcceptedByModem || + result.ReferenceKnown || + result.DeliveryConfirmed || + result.SubmissionStatus != "unconfirmed_without_reference" { + t.Fatalf("result = %#v", result) + } + client.assertDone(t) +} diff --git a/internal/device/snapshot.go b/internal/device/snapshot.go new file mode 100644 index 0000000..9e0f2ec --- /dev/null +++ b/internal/device/snapshot.go @@ -0,0 +1,351 @@ +package device + +import ( + "context" + "encoding/csv" + "fmt" + "io" + "strconv" + "strings" + "time" + "unicode" + + "vocat/internal/modem" +) + +func (manager *Manager) readSnapshot( + ctx context.Context, + id string, + candidate modem.Candidate, + client modem.Client, +) (Snapshot, error) { + snapshot := Snapshot{ + DeviceID: id, + Port: candidate.ATPort.OpenPath(), + OperatingMode: -1, + UpdatedAt: time.Now().UTC(), + } + ati, err := manager.command(ctx, client, "ATI") + if err != nil { + return snapshot, fmt.Errorf("probe modem: %w", err) + } + snapshot.Responsive = true + snapshot.Manufacturer, snapshot.Model, snapshot.Firmware = parseATI(ati.Lines) + if snapshot.Model == "" && !strings.EqualFold(candidate.Product, "Android") { + snapshot.Model = candidate.Product + } + + optional := func(command string) (modem.Response, bool) { + response, commandErr := manager.command(ctx, client, command) + if commandErr != nil { + snapshot.Warnings = append(snapshot.Warnings, commandErr.Error()) + return response, false + } + return response, true + } + + if response, ok := optional("AT+CPIN?"); ok { + snapshot.SIMStatus, snapshot.SIMReady = parseCPIN(response) + } + if response, ok := optional("AT+CSQ"); ok { + snapshot.SignalRaw, snapshot.SignalPercent, snapshot.RSSIDBm = parseCSQ(response) + } + if response, ok := optional(`AT+QENG="servingcell"`); ok { + metrics := parseQENG(response) + snapshot.AccessTech = metrics.AccessTech + snapshot.Band = metrics.Band + snapshot.Channel = metrics.Channel + snapshot.RSRP = metrics.RSRP + snapshot.RSRQ = metrics.RSRQ + snapshot.SINR = metrics.SINR + if metrics.RSSI != nil { + snapshot.RSSIDBm = metrics.RSSI + } + } + if response, ok := optional("AT+COPS?"); ok { + operator := parseCOPS(response) + snapshot.OperatorName = operator.Name + snapshot.OperatorCode = operator.Code + if snapshot.AccessTech == "" { + snapshot.AccessTech = operator.AccessTech + } + } + if response, ok := optional("AT+CGSN"); ok { + snapshot.IMEI = parseIdentifier( + response, + []string{"+CGSN:", "+GSN:"}, + 14, + 17, + ) + } + + ccid, ccidErr := manager.command(ctx, client, "AT+CCID") + if ccidErr != nil { + ccid, ccidErr = manager.command(ctx, client, "AT+QCCID") + } + if ccidErr != nil { + snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error()) + } else { + snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22) + } + if response, ok := optional("AT+CIMI"); ok { + snapshot.IMSI = parseIdentifier(response, []string{"+CIMI:"}, 10, 18) + } + if response, ok := optional("AT+CFUN?"); ok { + if mode, found := parseCFUN(response); found { + snapshot.OperatingMode = mode + snapshot.ModeKnown = true + snapshot.FlightMode = isRadioOffMode(mode) + snapshot.RadioOff = snapshot.FlightMode + } + } + + phone, warnings := manager.readPhoneNumber(ctx, client) + snapshot.Phone = phone + snapshot.Warnings = append(snapshot.Warnings, warnings...) + snapshot.UpdatedAt = time.Now().UTC() + return snapshot, nil +} + +func parseATI(lines []string) (manufacturer, model, firmware string) { + for _, line := range lines { + line = strings.TrimSpace(line) + upper := strings.ToUpper(line) + switch { + case strings.HasPrefix(upper, "REVISION:"): + firmware = strings.TrimSpace(strings.SplitN(line, ":", 2)[1]) + case strings.Contains(upper, "QUECTEL"): + manufacturer = line + case strings.HasPrefix(upper, "EC20") || strings.HasPrefix(upper, "EC25"): + model = line + } + } + return +} + +func parseCPIN(response modem.Response) (string, bool) { + value := strings.ToUpper(valueAfterPrefix(response, "+CPIN:")) + switch { + case strings.Contains(value, "READY"): + return "ready", true + case strings.Contains(value, "SIM PIN"): + return "pin_required", false + case strings.Contains(value, "SIM PUK"): + return "puk_required", false + case strings.Contains(value, "NOT INSERTED"): + return "not_inserted", false + case value == "": + return "unknown", false + default: + return strings.ToLower(strings.ReplaceAll(value, " ", "_")), false + } +} + +func parseCSQ(response modem.Response) (raw, percent, dbm *int) { + values := csvValues(valueAfterPrefix(response, "+CSQ:")) + if len(values) == 0 { + return nil, nil, nil + } + value, err := strconv.Atoi(values[0]) + if err != nil || value < 0 || value > 31 { + return nil, nil, nil + } + raw = intPointer(value) + scaled := (value*100 + 15) / 31 + percent = intPointer(scaled) + signalDBM := -113 + value*2 + dbm = intPointer(signalDBM) + return +} + +type qengMetrics struct { + AccessTech string + Band string + Channel string + RSSI *int + RSRP *int + RSRQ *int + SINR *int +} + +func parseQENG(response modem.Response) qengMetrics { + for _, line := range response.Lines { + if !strings.HasPrefix(strings.ToUpper(strings.TrimSpace(line)), "+QENG:") { + continue + } + values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1])) + if len(values) < 3 || !strings.EqualFold(values[0], "servingcell") { + continue + } + result := qengMetrics{AccessTech: strings.ToUpper(values[2])} + if strings.EqualFold(values[2], "LTE") && len(values) >= 17 { + result.Channel = values[8] + if values[9] != "" { + result.Band = "B" + values[9] + } + result.RSRP = parseOptionalInt(values[13]) + result.RSRQ = parseOptionalInt(values[14]) + result.RSSI = parseOptionalInt(values[15]) + result.SINR = parseOptionalInt(values[16]) + } + return result + } + return qengMetrics{} +} + +type operatorInfo struct { + Name string + Code string + AccessTech string +} + +func parseCOPS(response modem.Response) operatorInfo { + values := csvValues(valueAfterPrefix(response, "+COPS:")) + if len(values) < 3 { + return operatorInfo{} + } + result := operatorInfo{Name: values[2]} + format, _ := strconv.Atoi(values[1]) + if format == 2 { + result.Code = values[2] + result.Name = "" + } + if len(values) >= 4 { + result.AccessTech = accessTechnology(values[3]) + } + return result +} + +func accessTechnology(value string) string { + switch strings.TrimSpace(value) { + case "0": + return "GSM" + case "2": + return "UTRAN" + case "3": + return "EDGE" + case "4": + return "HSDPA" + case "5": + return "HSUPA" + case "6": + return "HSPA" + case "7": + return "LTE" + case "9": + return "NR5G" + default: + return "" + } +} + +func parseCFUN(response modem.Response) (int, bool) { + values := csvValues(valueAfterPrefix(response, "+CFUN:")) + if len(values) == 0 { + return 0, false + } + mode, err := strconv.Atoi(values[0]) + return mode, err == nil +} + +func isRadioOffMode(mode int) bool { + return mode == 0 || mode == 4 +} + +func valueAfterPrefix(response modem.Response, prefix string) string { + for _, line := range response.Lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToUpper(line), strings.ToUpper(prefix)) { + return strings.TrimSpace(line[len(prefix):]) + } + } + return "" +} + +func csvValues(value string) []string { + reader := csv.NewReader(strings.NewReader(value)) + reader.TrimLeadingSpace = true + reader.LazyQuotes = true + record, err := reader.Read() + if err != nil && err != io.EOF { + return nil + } + for index := range record { + record[index] = strings.TrimSpace(record[index]) + } + return record +} + +func firstDigitLine(response modem.Response, minimum, maximum int) string { + for _, line := range response.Lines { + value := strings.TrimSpace(line) + if len(value) < minimum || len(value) > maximum { + continue + } + if strings.IndexFunc(value, func(character rune) bool { + return !unicode.IsDigit(character) + }) < 0 { + return value + } + } + return "" +} + +func parseIdentifier( + response modem.Response, + prefixes []string, + minimum, maximum int, +) string { + for _, prefix := range prefixes { + value := strings.Trim(valueAfterPrefix(response, prefix), `" `) + if len(value) >= minimum && len(value) <= maximum && + strings.IndexFunc(value, func(character rune) bool { + return !unicode.IsDigit(character) + }) < 0 { + return value + } + } + return firstDigitLine(response, minimum, maximum) +} + +// parseICCIDIdentifier accepts all trailing hexadecimal F nibbles exposed from +// the fixed 10-octet EF-ICCID representation. A 19-digit ICCID has one filler +// nibble while an 18-digit ICCID has two; neither is part of the identifier. +func parseICCIDIdentifier( + response modem.Response, + prefixes []string, + minimum, maximum int, +) string { + normalize := func(value string) string { + value = strings.Trim(value, `" `) + value = strings.TrimRight(value, "Ff") + if len(value) >= minimum && len(value) <= maximum && + strings.IndexFunc(value, func(character rune) bool { return !unicode.IsDigit(character) }) < 0 { + return value + } + return "" + } + for _, prefix := range prefixes { + if value := normalize(valueAfterPrefix(response, prefix)); value != "" { + return value + } + } + for _, line := range response.Lines { + if value := normalize(strings.TrimSpace(line)); value != "" { + return value + } + } + return "" +} + +func parseOptionalInt(value string) *int { + number, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return nil + } + return intPointer(number) +} + +func intPointer(value int) *int { + return &value +} diff --git a/internal/device/test_helpers_test.go b/internal/device/test_helpers_test.go new file mode 100644 index 0000000..a62645a --- /dev/null +++ b/internal/device/test_helpers_test.go @@ -0,0 +1,232 @@ +package device + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" + + "vocat/internal/modem" +) + +type clientStep struct { + command string + response modem.Response + err error +} + +type promptClientStep struct { + command string + payload string + validateBody func(string) error + response modem.Response + err error +} + +type transcriptClient struct { + mu sync.Mutex + steps []clientStep + promptSteps []promptClientStep + urcs []string + unexpected error + closeCount int +} + +func (client *transcriptClient) ExecutePrompt( + ctx context.Context, + command string, + payload []byte, +) (modem.Response, error) { + if err := ctx.Err(); err != nil { + return modem.Response{}, err + } + client.mu.Lock() + defer client.mu.Unlock() + if len(client.promptSteps) == 0 { + client.unexpected = fmt.Errorf( + "unexpected prompt command %q with payload %q", + command, + payload, + ) + return modem.Response{}, client.unexpected + } + step := client.promptSteps[0] + client.promptSteps = client.promptSteps[1:] + if command != step.command { + client.unexpected = fmt.Errorf( + "prompt command %q, want %q", + command, + step.command, + ) + return modem.Response{}, client.unexpected + } + if step.validateBody != nil { + if err := step.validateBody(string(payload)); err != nil { + client.unexpected = err + return modem.Response{}, err + } + } else if string(payload) != step.payload { + client.unexpected = fmt.Errorf( + "prompt payload %q, want %q", + payload, + step.payload, + ) + return modem.Response{}, client.unexpected + } + response := step.response + if response.Command == "" { + response.Command = command + } + return response, step.err +} + +func (client *transcriptClient) Execute( + ctx context.Context, + command string, +) (modem.Response, error) { + if err := ctx.Err(); err != nil { + return modem.Response{}, err + } + client.mu.Lock() + defer client.mu.Unlock() + if len(client.steps) == 0 { + client.unexpected = fmt.Errorf("unexpected command %q", command) + return modem.Response{}, client.unexpected + } + step := client.steps[0] + client.steps = client.steps[1:] + if command != step.command { + client.unexpected = fmt.Errorf("command %q, want %q", command, step.command) + return modem.Response{}, client.unexpected + } + response := step.response + if response.Command == "" { + response.Command = command + } + return response, step.err +} + +func (client *transcriptClient) WaitURC( + ctx context.Context, + predicate func(string) bool, +) (string, error) { + if err := ctx.Err(); err != nil { + return "", err + } + client.mu.Lock() + defer client.mu.Unlock() + for index, line := range client.urcs { + if predicate(line) { + client.urcs = append(client.urcs[:index], client.urcs[index+1:]...) + return line, nil + } + } + client.unexpected = errors.New("no matching URC in transcript") + return "", client.unexpected +} + +func (client *transcriptClient) Close() error { + client.mu.Lock() + client.closeCount++ + client.mu.Unlock() + return nil +} + +func (client *transcriptClient) assertDone(t *testing.T) { + t.Helper() + client.mu.Lock() + defer client.mu.Unlock() + if client.unexpected != nil { + t.Fatalf("transcript error: %v", client.unexpected) + } + if len(client.steps) != 0 { + t.Fatalf("%d command transcript steps remain; next is %q", len(client.steps), client.steps[0].command) + } + if len(client.promptSteps) != 0 { + t.Fatalf( + "%d prompt transcript steps remain; next is %q", + len(client.promptSteps), + client.promptSteps[0].command, + ) + } +} + +type staticDiscoverer struct { + candidates []modem.Candidate + err error +} + +func (discoverer staticDiscoverer) Discover( + ctx context.Context, +) ([]modem.Candidate, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + result := append([]modem.Candidate(nil), discoverer.candidates...) + return result, discoverer.err +} + +type staticOpener struct { + mu sync.Mutex + client modem.Client + err error + openCount int + ports []modem.Port +} + +func (opener *staticOpener) Open( + ctx context.Context, + port modem.Port, +) (modem.Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + opener.mu.Lock() + defer opener.mu.Unlock() + opener.openCount++ + opener.ports = append(opener.ports, port) + return opener.client, opener.err +} + +func newStartedTestManager( + t *testing.T, + client modem.Client, +) (*Manager, string) { + t.Helper() + const id = "quectel-test-ec20" + opener := &staticOpener{client: client} + manager, err := NewManager(Options{ + Discoverer: staticDiscoverer{candidates: []modem.Candidate{{ + ID: id, + VendorID: "2c7c", + ProductID: "0125", + Manufacturer: "Quectel", + Product: "EC20", + ATPort: modem.Port{ + Path: "/dev/ttyUSB2", + Name: "ttyUSB2", + InterfaceNumber: 0x04, + Role: modem.PortRoleAT, + }, + }}}, + Opener: opener, + CommandTimeout: time.Second, + LongTimeout: time.Second, + }) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if err := manager.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + t.Cleanup(func() { + _ = manager.Stop(context.Background()) + }) + return manager, id +} + +func okResponse(lines ...string) modem.Response { + return modem.Response{Lines: lines, Final: "OK"} +} diff --git a/internal/device/types.go b/internal/device/types.go new file mode 100644 index 0000000..54fe878 --- /dev/null +++ b/internal/device/types.go @@ -0,0 +1,232 @@ +package device + +import ( + "errors" + "time" + + "vocat/internal/modem" +) + +var ( + ErrNotStarted = errors.New("device manager is not started") + ErrNotFound = errors.New("device not found") + ErrNoATPort = errors.New("device has no usable AT port") + ErrSMSPromptUnsupported = errors.New("device AT client does not support SMS prompt mode") + ErrSMSInvalidRecipient = errors.New("invalid SMS recipient") + ErrSMSEmpty = errors.New("SMS text is empty") + ErrSMSTooLong = errors.New("SMS exceeds one-message encoding limit") + ErrSMSReferenceMissing = errors.New("modem completed SMS command without a message reference") + ErrSMSInvalidMessageIndex = errors.New("invalid SMS message index") + ErrDataBackendUnavailable = errors.New("cellular data backend is unavailable") + ErrInvalidNetworkAPN = errors.New("invalid cellular APN") + ErrRegionBlocked = errors.New("sim card home region is not served") + ErrUSSDSessionNotFound = errors.New("ussd session not found or already closed") +) + +type NetworkRequest struct { + Enabled bool `json:"enabled"` + APN string `json:"apn"` + IPVersion string `json:"ipVersion"` +} + +type NetworkResult struct { + Enabled bool `json:"enabled"` + Backend string `json:"backend"` + Interface string `json:"interface,omitempty"` + ControlDevice string `json:"controlDevice,omitempty"` + APN string `json:"apn,omitempty"` + IPVersion string `json:"ipVersion,omitempty"` + Detail string `json:"detail,omitempty"` +} + +type USBNetMode struct { + Mode int `json:"mode"` + Name string `json:"name"` +} + +type OperatorSelection struct { + Mode int `json:"mode"` + Format int `json:"format"` + Operator string `json:"operator"` + AccessTechnology string `json:"accessTechnology,omitempty"` +} + +type Device struct { + ID string `json:"id"` + Candidate modem.Candidate `json:"candidate"` + Snapshot *Snapshot `json:"snapshot,omitempty"` + LastError string `json:"lastError,omitempty"` + Discovered bool `json:"discovered"` + LastUpdated time.Time `json:"lastUpdated,omitempty"` +} + +type PhoneNumber struct { + Number string `json:"number"` + Source string `json:"source"` + Status string `json:"status"` +} + +const ( + PhoneSourceCNUM = "at_cnum" + PhoneSourceOwnNumber = "sim_own_number" + PhoneSourceEFMSISDN = "usim_ef_msisdn" +) + +type Snapshot struct { + DeviceID string `json:"deviceId"` + Port string `json:"port"` + Responsive bool `json:"responsive"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + Firmware string `json:"firmware"` + SIMStatus string `json:"simStatus"` + SIMReady bool `json:"simReady"` + SignalRaw *int `json:"signalRaw,omitempty"` + SignalPercent *int `json:"signalPercent,omitempty"` + RSSIDBm *int `json:"rssiDbm,omitempty"` + RSRP *int `json:"rsrp,omitempty"` + RSRQ *int `json:"rsrq,omitempty"` + SINR *int `json:"sinr,omitempty"` + AccessTech string `json:"accessTech"` + Band string `json:"band"` + Channel string `json:"channel"` + OperatorName string `json:"operatorName"` + OperatorCode string `json:"operatorCode"` + IMEI string `json:"imei"` + ICCID string `json:"iccid"` + IMSI string `json:"imsi"` + OperatingMode int `json:"operatingMode"` + ModeKnown bool `json:"modeKnown"` + FlightMode bool `json:"flightMode"` + RadioOff bool `json:"radioOff"` + Phone PhoneNumber `json:"phone"` + Warnings []string `json:"warnings,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type USSDResult struct { + Code string `json:"code"` + Text string `json:"text"` + Raw string `json:"raw"` + DCS *int `json:"dcs,omitempty"` + // Status describes the dialog state: "final", "awaiting_input", + // "terminated", or "failed". + Status string `json:"status,omitempty"` + // SessionID identifies an open dialog when Status is "awaiting_input". + SessionID string `json:"sessionId,omitempty"` + // Continueable reports whether the network expects more input on SessionID. + Continueable bool `json:"continueable,omitempty"` +} + +type FlightResult struct { + PreviousMode int `json:"previousMode"` + CurrentMode int `json:"currentMode"` + Changed bool `json:"changed"` + FlightMode bool `json:"flightMode"` + RadioOff bool `json:"radioOff"` +} + +type SMSEncoding string + +const ( + SMSEncodingGSM7Text SMSEncoding = "gsm7_text" + SMSEncodingGSM7PDU SMSEncoding = "gsm7_pdu" + SMSEncodingUCS2PDU SMSEncoding = "ucs2_pdu" + SMSEncoding8BitPDU SMSEncoding = "8bit_pdu" + SMSEncodingUnknown SMSEncoding = "unknown" +) + +type SMSStorageStatus string + +const ( + SMSStatusReceivedUnread SMSStorageStatus = "received_unread" + SMSStatusReceivedRead SMSStorageStatus = "received_read" + SMSStatusStoredUnsent SMSStorageStatus = "stored_unsent" + SMSStatusStoredSent SMSStorageStatus = "stored_sent" + SMSStatusUnknown SMSStorageStatus = "unknown" +) + +type SMSDirection string + +const ( + SMSDirectionReceived SMSDirection = "received" + SMSDirectionSubmitted SMSDirection = "submitted" + SMSDirectionStatusReport SMSDirection = "status_report" + SMSDirectionUnknown SMSDirection = "unknown" +) + +type SMSConcatInfo struct { + Reference int `json:"reference"` + Total int `json:"total"` + Sequence int `json:"sequence"` +} + +// SMSSubmitTPDU is one modem-independent SMS-SUBMIT transfer unit. TPDU does +// not include the SMSC-length octet used by AT+CMGS PDU mode, so it can be +// embedded directly in an RP-DATA message for SMS over IMS. +type SMSSubmitTPDU struct { + To string + Encoding SMSEncoding + TPDU []byte + Part int + Total int + ConcatReference *int +} + +type SMSMessage struct { + Index int `json:"index"` + Storage string `json:"storage,omitempty"` + StorageStatus SMSStorageStatus `json:"storageStatus"` + Direction SMSDirection `json:"direction"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` + ServiceCenter string `json:"serviceCenter,omitempty"` + Text string `json:"text"` + Encoding SMSEncoding `json:"encoding"` + ServiceCenterTimestamp *time.Time `json:"serviceCenterTimestamp,omitempty"` + DischargeTimestamp *time.Time `json:"dischargeTimestamp,omitempty"` + MessageReference *int `json:"messageReference,omitempty"` + StatusCode *int `json:"statusCode,omitempty"` + DeliveryStatus string `json:"deliveryStatus,omitempty"` + Concat *SMSConcatInfo `json:"concat,omitempty"` + ProtocolID int `json:"protocolId"` + DataCodingScheme int `json:"dataCodingScheme"` + ModemLength int `json:"modemLength"` + RawPDU string `json:"rawPdu"` + RawUserData string `json:"rawUserData,omitempty"` + DecodeError string `json:"decodeError,omitempty"` +} + +type SMSPartResult struct { + Part int `json:"part"` + Total int `json:"total"` + MessageReference int `json:"messageReference"` + ReferenceKnown bool `json:"referenceKnown"` + AcceptedByModem bool `json:"acceptedByModem"` + SubmissionStatus string `json:"submissionStatus"` + ModemFinal string `json:"modemFinal"` + ModemEvidence []string `json:"modemEvidence"` + SubmittedAt time.Time `json:"submittedAt"` +} + +// SMSSendResult proves only that the modem accepted the submit request. A +// +CMGS message reference is not a network delivery receipt. +type SMSSendResult struct { + To string `json:"to"` + Encoding SMSEncoding `json:"encoding"` + MessageReference int `json:"messageReference"` + ReferenceKnown bool `json:"referenceKnown"` + AcceptedByModem bool `json:"acceptedByModem"` + DeliveryConfirmed bool `json:"deliveryConfirmed"` + SubmissionStatus string `json:"submissionStatus"` + DeliveryStatus string `json:"deliveryStatus"` + ModemFinal string `json:"modemFinal"` + ModemEvidence []string `json:"modemEvidence"` + SubmittedAt time.Time `json:"submittedAt"` + PartsTotal int `json:"partsTotal"` + PartsAttempted int `json:"partsAttempted"` + PartsAccepted int `json:"partsAccepted"` + AllPartsAccepted bool `json:"allPartsAccepted"` + ConcatReference *int `json:"concatReference,omitempty"` + PartResults []SMSPartResult `json:"partResults"` +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..71b499c --- /dev/null +++ b/internal/i18n/i18n.go @@ -0,0 +1,145 @@ +// Package i18n translates the backend's user-facing Chinese strings into +// English when the persisted UI language is English. +// +// The SPA keeps its own Chinese-keyed dictionary (web/src/lib/i18n-en.ts) for +// static UI copy. This package covers the strings the Go backend generates as +// DATA — status text, error messages, probe hints, and country names — which +// the SPA renders verbatim and therefore cannot translate client-side. +// +// The product serves a single administrator whose language is persisted in the +// settings store, so a process-level language value is sufficient; the server +// refreshes it on startup and whenever the preference is read or written. +package i18n + +import ( + "fmt" + "sync/atomic" +) + +// current holds the active UI language: "en" or "zh". It defaults to Chinese so +// the backend behaves exactly as it always has unless the persisted preference +// is explicitly English — the server syncs it from the settings store on +// startup and on every preferences read/write. +var current atomic.Value // stores string + +func init() { + current.Store("zh") +} + +// Set records the active UI language. Anything other than "zh" is treated as +// English, matching the SPA's fallback. +func Set(language string) { + if language == "zh" { + current.Store("zh") + return + } + current.Store("en") +} + +// Lang reports the active UI language ("en" or "zh"). +func Lang() string { + if language, ok := current.Load().(string); ok { + return language + } + return "zh" +} + +// T translates a Chinese string to English when the active language is +// English; otherwise it returns the input unchanged. Strings with no entry are +// returned as-is (Chinese), mirroring the SPA dictionary's fallback. +func T(zh string) string { + if Lang() != "en" { + return zh + } + if en, ok := zhToEn[zh]; ok { + return en + } + return zh +} + +// Tf translates a Chinese printf-style template and then formats it with args, +// so interpolated values land inside the translated sentence. +func Tf(template string, args ...any) string { + return fmt.Sprintf(T(template), args...) +} + +// zhToEn maps every user-facing Chinese string the backend emits onto its +// English equivalent. Keys must match the source string byte-for-byte. +var zhToEn = map[string]string{ + // ---- eSIM ---- + "已启用": "Enabled", + "已禁用": "Disabled", + "该 eSIM 操作暂未实现:当前仅支持列出 Profile 与切卡(启用已安装的 Profile),不支持下载/删除/改名": "This eSIM operation is not available: only listing profiles and switching (enabling an already-installed profile) are supported; download, delete, and rename are not.", + + // ---- devices ---- + "设备数量已达上限,最多只能添加 %d 台设备": "Device limit reached; at most %d devices can be added.", + "SIM 卡归属地为%s(MCC %s),本服务不向该地区卡片提供数据/短信/VoWiFi": "The SIM's home region is %s (MCC %s); this service does not provide data, SMS, or VoWiFi to cards from that region.", + + // ---- settings / update ---- + "未配置受信任的软件更新源;不会从未知地址下载或执行文件。": "No trusted update source is configured; no files will be downloaded or executed from unknown addresses.", + "未配置受信任的软件更新源;未执行任何更新。": "No trusted update source is configured; no update was performed.", + + // ---- proxy probe / save ---- + "检查地址、端口、防火墙与上游代理监听状态。": "Check the address, port, firewall, and that the upstream proxy is listening.", + "该代理不能承载 ePDG 所需的 UDP;启用上游 SOCKS5 UDP 转发后重试。": "This proxy cannot carry the UDP that ePDG requires; enable upstream SOCKS5 UDP forwarding and retry.", + "TCP 握手、认证和 UDP ASSOCIATE 均通过。": "TCP handshake, authentication, and UDP ASSOCIATE all passed.", + "代理已保存;UDP ASSOCIATE 尚未通过。": "Proxy saved; UDP ASSOCIATE has not passed yet.", + "代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。": "Proxy saved; SOCKS5 authentication and UDP ASSOCIATE both passed.", + "代理不能承载 VoWiFi 所需的 UDP。": "The proxy cannot carry the UDP that VoWiFi requires.", + "SOCKS5 认证与 UDP ASSOCIATE 探测通过。": "SOCKS5 authentication and UDP ASSOCIATE probe passed.", + + // ---- country names (upstream proxy country rules) ---- + "中国": "China", + "中国香港": "Hong Kong (China)", + "中国澳门": "Macau (China)", + "中国台湾": "Taiwan (China)", + "美国": "United States", + "加拿大": "Canada", + "英国": "United Kingdom", + "德国": "Germany", + "法国": "France", + "意大利": "Italy", + "西班牙": "Spain", + "葡萄牙": "Portugal", + "荷兰": "Netherlands", + "比利时": "Belgium", + "瑞士": "Switzerland", + "奥地利": "Austria", + "爱尔兰": "Ireland", + "丹麦": "Denmark", + "瑞典": "Sweden", + "挪威": "Norway", + "芬兰": "Finland", + "波兰": "Poland", + "捷克": "Czechia", + "希腊": "Greece", + "罗马尼亚": "Romania", + "匈牙利": "Hungary", + "乌克兰": "Ukraine", + "俄罗斯": "Russia", + "土耳其": "Türkiye", + "日本": "Japan", + "韩国": "South Korea", + "新加坡": "Singapore", + "马来西亚": "Malaysia", + "泰国": "Thailand", + "越南": "Vietnam", + "菲律宾": "Philippines", + "印度尼西亚": "Indonesia", + "印度": "India", + "巴基斯坦": "Pakistan", + "阿联酋": "United Arab Emirates", + "沙特阿拉伯": "Saudi Arabia", + "以色列": "Israel", + "澳大利亚": "Australia", + "新西兰": "New Zealand", + "巴西": "Brazil", + "墨西哥": "Mexico", + "阿根廷": "Argentina", + "智利": "Chile", + "哥伦比亚": "Colombia", + "南非": "South Africa", + "埃及": "Egypt", + "尼日利亚": "Nigeria", + "肯尼亚": "Kenya", +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..062e949 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -0,0 +1,49 @@ +package i18n + +import "testing" + +func TestDefaultIsChinese(t *testing.T) { + Set("zh") // ensure a clean Chinese baseline regardless of test order + if got := T("已启用"); got != "已启用" { + t.Fatalf("zh: T(已启用) = %q, want 已启用", got) + } +} + +func TestEnglishTranslation(t *testing.T) { + Set("en") + defer Set("zh") + if got := T("已启用"); got != "Enabled" { + t.Fatalf("en: T(已启用) = %q, want Enabled", got) + } + if got := T("美国"); got != "United States" { + t.Fatalf("en: T(美国) = %q, want United States", got) + } + // unknown strings fall back to the Chinese key unchanged + if got := T("未收录的字符串"); got != "未收录的字符串" { + t.Fatalf("en: unknown string = %q, want fallback unchanged", got) + } +} + +func TestTfInterpolation(t *testing.T) { + Set("en") + defer Set("zh") + got := Tf("设备数量已达上限,最多只能添加 %d 台设备", 5) + want := "Device limit reached; at most 5 devices can be added." + if got != want { + t.Fatalf("Tf = %q, want %q", got, want) + } + // region reason: country name itself is translated before interpolation + got = Tf("SIM 卡归属地为%s(MCC %s),本服务不向该地区卡片提供数据/短信/VoWiFi", T("中国"), "460") + want = "The SIM's home region is China (MCC 460); this service does not provide data, SMS, or VoWiFi to cards from that region." + if got != want { + t.Fatalf("Tf region = %q, want %q", got, want) + } +} + +func TestSetNormalizesUnknownToEnglish(t *testing.T) { + Set("fr") // unsupported -> treated as English + defer Set("zh") + if Lang() != "en" { + t.Fatalf("Lang after Set(fr) = %q, want en", Lang()) + } +} diff --git a/internal/loghub/hub.go b/internal/loghub/hub.go new file mode 100644 index 0000000..590cc40 --- /dev/null +++ b/internal/loghub/hub.go @@ -0,0 +1,278 @@ +package loghub + +import ( + "context" + "log/slog" + "sort" + "strings" + "sync" + "time" +) + +// Entry is the stable, secret-neutral representation exposed by the log API. +// Callers remain responsible for never adding credentials or keying material +// to slog attributes. +type Entry struct { + Time time.Time `json:"time"` + Level string `json:"level"` + Message string `json:"message"` + Caller string `json:"caller,omitempty"` + Fields map[string]any `json:"fields,omitempty"` +} + +type core struct { + mu sync.RWMutex + capacity int + entries []Entry + subscribers map[uint64]chan Entry + nextID uint64 +} + +// Hub is both a slog.Handler and a bounded live log source. +type Hub struct { + next slog.Handler + core *core + attrs []slog.Attr + groups []string +} + +func New(next slog.Handler, capacity int) *Hub { + if next == nil { + next = slog.NewTextHandler(discardWriter{}, nil) + } + if capacity < 100 { + capacity = 100 + } + return &Hub{ + next: next, + core: &core{ + capacity: capacity, + entries: make([]Entry, 0, capacity), + subscribers: make(map[uint64]chan Entry), + }, + } +} + +func (h *Hub) Enabled(ctx context.Context, level slog.Level) bool { + return h.next.Enabled(ctx, level) +} + +func (h *Hub) Handle(ctx context.Context, record slog.Record) error { + err := h.next.Handle(ctx, record) + fields := make(map[string]any) + for _, attr := range h.attrs { + appendAttribute(fields, h.groups, attr) + } + record.Attrs(func(attr slog.Attr) bool { + appendAttribute(fields, h.groups, attr) + return true + }) + entry := Entry{ + Time: record.Time.UTC(), + Level: levelName(record.Level), + Message: record.Message, + Fields: fields, + } + if len(fields) == 0 { + entry.Fields = nil + } + h.publish(entry) + return err +} + +func (h *Hub) WithAttrs(attrs []slog.Attr) slog.Handler { + nextAttrs := append(append([]slog.Attr(nil), h.attrs...), attrs...) + return &Hub{ + next: h.next.WithAttrs(attrs), + core: h.core, + attrs: nextAttrs, + groups: append([]string(nil), h.groups...), + } +} + +func (h *Hub) WithGroup(name string) slog.Handler { + name = strings.TrimSpace(name) + groups := append([]string(nil), h.groups...) + if name != "" { + groups = append(groups, name) + } + return &Hub{ + next: h.next.WithGroup(name), + core: h.core, + attrs: append([]slog.Attr(nil), h.attrs...), + groups: groups, + } +} + +func (h *Hub) publish(entry Entry) { + h.core.mu.Lock() + if len(h.core.entries) == h.core.capacity { + copy(h.core.entries, h.core.entries[1:]) + h.core.entries[len(h.core.entries)-1] = cloneEntry(entry) + } else { + h.core.entries = append(h.core.entries, cloneEntry(entry)) + } + for _, subscriber := range h.core.subscribers { + select { + case subscriber <- cloneEntry(entry): + default: + select { + case <-subscriber: + default: + } + select { + case subscriber <- cloneEntry(entry): + default: + } + } + } + h.core.mu.Unlock() +} + +// History returns the newest matching entries in chronological order. +func (h *Hub) History(limit int, minimum slog.Level, search string) []Entry { + if limit < 1 { + limit = 1 + } + if limit > h.core.capacity { + limit = h.core.capacity + } + search = strings.ToLower(strings.TrimSpace(search)) + h.core.mu.RLock() + result := make([]Entry, 0, limit) + for index := len(h.core.entries) - 1; index >= 0 && len(result) < limit; index-- { + entry := h.core.entries[index] + if parseLevel(entry.Level) < minimum { + continue + } + if search != "" && !entryContains(entry, search) { + continue + } + result = append(result, cloneEntry(entry)) + } + h.core.mu.RUnlock() + sort.SliceStable(result, func(i, j int) bool { return result[i].Time.Before(result[j].Time) }) + return result +} + +func (h *Hub) Subscribe(buffer int) (<-chan Entry, func()) { + if buffer < 1 { + buffer = 1 + } + if buffer > 1000 { + buffer = 1000 + } + channel := make(chan Entry, buffer) + h.core.mu.Lock() + id := h.core.nextID + h.core.nextID++ + h.core.subscribers[id] = channel + h.core.mu.Unlock() + var once sync.Once + cancel := func() { + once.Do(func() { + h.core.mu.Lock() + delete(h.core.subscribers, id) + close(channel) + h.core.mu.Unlock() + }) + } + return channel, cancel +} + +func appendAttribute(fields map[string]any, groups []string, attr slog.Attr) { + attr.Value = attr.Value.Resolve() + if attr.Equal(slog.Attr{}) { + return + } + target := fields + for _, group := range groups { + next, ok := target[group].(map[string]any) + if !ok { + next = make(map[string]any) + target[group] = next + } + target = next + } + if attr.Value.Kind() == slog.KindGroup { + group := make(map[string]any) + for _, child := range attr.Value.Group() { + appendAttribute(group, nil, child) + } + target[attr.Key] = group + return + } + target[attr.Key] = attr.Value.Any() +} + +func levelName(level slog.Level) string { + switch { + case level >= slog.LevelError: + return "error" + case level >= slog.LevelWarn: + return "warn" + case level >= slog.LevelInfo: + return "info" + default: + return "debug" + } +} + +func parseLevel(value string) slog.Level { + switch strings.ToLower(strings.TrimSpace(value)) { + case "error": + return slog.LevelError + case "warn", "warning": + return slog.LevelWarn + case "info", "": + return slog.LevelInfo + default: + return slog.LevelDebug + } +} + +func entryContains(entry Entry, search string) bool { + if strings.Contains(strings.ToLower(entry.Message), search) || + strings.Contains(strings.ToLower(entry.Caller), search) { + return true + } + for key, value := range entry.Fields { + if strings.Contains(strings.ToLower(key), search) || + strings.Contains(strings.ToLower(toString(value)), search) { + return true + } + } + return false +} + +func cloneEntry(entry Entry) Entry { + if entry.Fields != nil { + entry.Fields = cloneMap(entry.Fields) + } + return entry +} + +func cloneMap(source map[string]any) map[string]any { + result := make(map[string]any, len(source)) + for key, value := range source { + if nested, ok := value.(map[string]any); ok { + result[key] = cloneMap(nested) + } else { + result[key] = value + } + } + return result +} + +func toString(value any) string { + if stringValue, ok := value.(string); ok { + return stringValue + } + return slog.AnyValue(value).String() +} + +type discardWriter struct{} + +func (discardWriter) Write(data []byte) (int, error) { + return len(data), nil +} diff --git a/internal/loghub/hub_test.go b/internal/loghub/hub_test.go new file mode 100644 index 0000000..01207a0 --- /dev/null +++ b/internal/loghub/hub_test.go @@ -0,0 +1,49 @@ +package loghub + +import ( + "context" + "io" + "log/slog" + "testing" + "time" +) + +func TestHubHistoryFiltersAndBounds(t *testing.T) { + hub := New(slog.NewTextHandler(io.Discard, nil), 100) + logger := slog.New(hub) + logger.Debug("hidden") + logger.Info("modem ready", "device", "ec20") + logger.Warn("retry", "device", "ec20") + + history := hub.History(10, slog.LevelInfo, "ec20") + if len(history) != 2 { + t.Fatalf("History() length = %d, want 2", len(history)) + } + if history[0].Message != "modem ready" || history[1].Message != "retry" { + t.Fatalf("History() = %#v", history) + } + history[0].Fields["device"] = "changed" + again := hub.History(10, slog.LevelInfo, "") + if again[0].Fields["device"] != "ec20" { + t.Fatal("History() exposed mutable internal fields") + } +} + +func TestHubSubscription(t *testing.T) { + hub := New(slog.NewTextHandler(io.Discard, nil), 100) + entries, cancel := hub.Subscribe(1) + defer cancel() + record := slog.NewRecord(time.Now(), slog.LevelError, "failure", 0) + record.Add("stage", "ims") + if err := hub.Handle(context.Background(), record); err != nil { + t.Fatal(err) + } + select { + case entry := <-entries: + if entry.Level != "error" || entry.Fields["stage"] != "ims" { + t.Fatalf("entry = %#v", entry) + } + case <-time.After(time.Second): + t.Fatal("timed out waiting for log entry") + } +} diff --git a/internal/modem/default_discovery_linux.go b/internal/modem/default_discovery_linux.go new file mode 100644 index 0000000..c5ddf75 --- /dev/null +++ b/internal/modem/default_discovery_linux.go @@ -0,0 +1,7 @@ +//go:build linux + +package modem + +func NewSystemDiscoverer() Discoverer { + return NewSysFSDiscoverer("/sys", "/dev") +} diff --git a/internal/modem/default_discovery_stub.go b/internal/modem/default_discovery_stub.go new file mode 100644 index 0000000..6733c4d --- /dev/null +++ b/internal/modem/default_discovery_stub.go @@ -0,0 +1,7 @@ +//go:build !linux + +package modem + +func NewSystemDiscoverer() Discoverer { + return unsupportedDiscoverer{} +} diff --git a/internal/modem/discovery.go b/internal/modem/discovery.go new file mode 100644 index 0000000..a254b17 --- /dev/null +++ b/internal/modem/discovery.go @@ -0,0 +1,307 @@ +package modem + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +const quectelVendorID = "2c7c" + +type SysFSDiscoverer struct { + SysRoot string + DevRoot string +} + +func NewSysFSDiscoverer(sysRoot, devRoot string) *SysFSDiscoverer { + return &SysFSDiscoverer{ + SysRoot: filepath.Clean(sysRoot), + DevRoot: filepath.Clean(devRoot), + } +} + +type discoveredUSBDevice struct { + candidate Candidate + ports map[string]Port +} + +func (d *SysFSDiscoverer) Discover(ctx context.Context) ([]Candidate, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + usbRoot := filepath.Join(d.SysRoot, "bus", "usb", "devices") + entries, err := os.ReadDir(usbRoot) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("discover Quectel USB devices: %w", err) + } + + aliases := readSerialAliases(filepath.Join(d.DevRoot, "serial", "by-id")) + devices := make(map[string]*discoveredUSBDevice) + for _, entry := range entries { + if err := ctx.Err(); err != nil { + return nil, err + } + interfaceNumber, ok := parseUSBInterfaceName(entry.Name()) + if !ok { + continue + } + interfacePath := filepath.Join(usbRoot, entry.Name()) + resolvedInterface, err := filepath.EvalSymlinks(interfacePath) + if err != nil { + resolvedInterface = interfacePath + } + if value, err := readHexByte(filepath.Join(resolvedInterface, "bInterfaceNumber")); err == nil { + interfaceNumber = value + } + + deviceName := strings.SplitN(entry.Name(), ":", 2)[0] + devicePath := filepath.Join(usbRoot, deviceName) + resolvedDevice, err := filepath.EvalSymlinks(devicePath) + if err != nil { + resolvedDevice = devicePath + } + vendorID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idVendor"))) + if vendorID != quectelVendorID { + continue + } + + state := devices[deviceName] + if state == nil { + productID := strings.ToLower(readTrimmed(filepath.Join(resolvedDevice, "idProduct"))) + serialNumber := readTrimmed(filepath.Join(resolvedDevice, "serial")) + state = &discoveredUSBDevice{ + candidate: Candidate{ + ID: candidateID(productID, serialNumber, deviceName), + VendorID: vendorID, + ProductID: productID, + Manufacturer: readTrimmed(filepath.Join(resolvedDevice, "manufacturer")), + Product: readTrimmed(filepath.Join(resolvedDevice, "product")), + SerialNumber: serialNumber, + USBPath: devicePath, + }, + ports: make(map[string]Port), + } + devices[deviceName] = state + } + + ttyNames, qmiControls, networkInterfaces := scanUSBInterface(resolvedInterface) + for _, name := range ttyNames { + if !strings.HasPrefix(name, "ttyUSB") && !strings.HasPrefix(name, "ttyACM") { + continue + } + path := filepath.Join(d.DevRoot, name) + state.ports[name] = Port{ + Path: path, + StablePath: aliases[name], + Name: name, + InterfaceNumber: interfaceNumber, + Role: quecPortRole(interfaceNumber, name), + } + } + if state.candidate.QMIControl == "" && len(qmiControls) > 0 { + state.candidate.QMIControl = filepath.Join(d.DevRoot, qmiControls[0]) + } + if state.candidate.NetworkInterface == "" && len(networkInterfaces) > 0 { + state.candidate.NetworkInterface = networkInterfaces[0] + } + } + + result := make([]Candidate, 0, len(devices)) + for _, state := range devices { + state.candidate.Ports = make([]Port, 0, len(state.ports)) + for _, port := range state.ports { + state.candidate.Ports = append(state.candidate.Ports, port) + } + sort.Slice(state.candidate.Ports, func(i, j int) bool { + left, right := state.candidate.Ports[i], state.candidate.Ports[j] + if left.InterfaceNumber != right.InterfaceNumber { + return left.InterfaceNumber < right.InterfaceNumber + } + return left.Name < right.Name + }) + state.candidate.ATPort = selectATPort(state.candidate.Ports) + result = append(result, state.candidate) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result, nil +} + +func parseUSBInterfaceName(name string) (int, bool) { + _, suffix, ok := strings.Cut(name, ":") + if !ok { + return 0, false + } + _, numberText, ok := strings.Cut(suffix, ".") + if !ok || numberText == "" { + return 0, false + } + number, err := strconv.ParseInt(numberText, 10, 32) + return int(number), err == nil +} + +func readHexByte(path string) (int, error) { + value := readTrimmed(path) + number, err := strconv.ParseUint(value, 16, 8) + return int(number), err +} + +func readTrimmed(path string) string { + value, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(value)) +} + +func scanUSBInterface(root string) (ttyNames, qmiControls, networkInterfaces []string) { + ttySeen := make(map[string]struct{}) + qmiSeen := make(map[string]struct{}) + netSeen := make(map[string]struct{}) + _ = filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return nil + } + name := entry.Name() + switch { + case entry.IsDir() && (strings.HasPrefix(name, "ttyUSB") || strings.HasPrefix(name, "ttyACM")): + ttySeen[name] = struct{}{} + case strings.HasPrefix(name, "cdc-wdm"): + qmiSeen[name] = struct{}{} + case entry.IsDir() && filepath.Base(filepath.Dir(path)) == "net": + netSeen[name] = struct{}{} + } + return nil + }) + for name := range ttySeen { + ttyNames = append(ttyNames, name) + } + for name := range qmiSeen { + qmiControls = append(qmiControls, name) + } + for name := range netSeen { + networkInterfaces = append(networkInterfaces, name) + } + sort.Strings(ttyNames) + sort.Strings(qmiControls) + sort.Strings(networkInterfaces) + return +} + +func readSerialAliases(root string) map[string]string { + result := make(map[string]string) + entries, err := os.ReadDir(root) + if err != nil { + return result + } + for _, entry := range entries { + path := filepath.Join(root, entry.Name()) + target, err := os.Readlink(path) + if err != nil { + continue + } + name := filepath.Base(filepath.Clean(target)) + if strings.HasPrefix(name, "ttyUSB") || strings.HasPrefix(name, "ttyACM") { + if existing := result[name]; existing == "" || path < existing { + result[name] = path + } + } + } + return result +} + +func candidateID(productID, serialNumber, usbName string) string { + serialNumber = strings.TrimSpace(serialNumber) + if serialNumber != "" && !strings.EqualFold(serialNumber, "android") { + return "quectel-" + sanitizeID(serialNumber) + } + return "quectel-" + sanitizeID(productID+"-"+usbName) +} + +func sanitizeID(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + var result strings.Builder + for _, character := range value { + if character >= 'a' && character <= 'z' || + character >= '0' && character <= '9' || + character == '-' || character == '_' { + result.WriteRune(character) + } else { + result.WriteByte('-') + } + } + return strings.Trim(result.String(), "-") +} + +func quecPortRole(interfaceNumber int, name string) PortRole { + // Quectel exposes the same logical ports under more than one USB + // composition. In both layouts seen on EC20/EC25 hardware the kernel + // stable tty name is the stronger hint: ttyUSB0 is diagnostic and + // ttyUSB2 is the primary AT port, even when their interface numbers are + // 00/02 instead of 02/04. + switch name { + case "ttyUSB0": + return PortRoleDiagnostic + case "ttyUSB1": + return PortRoleNMEA + case "ttyUSB2": + return PortRoleAT + case "ttyUSB3": + return PortRoleModem + } + switch interfaceNumber { + case 0x02: + return PortRoleDiagnostic + case 0x03: + return PortRoleNMEA + case 0x04: + return PortRoleAT + case 0x05: + return PortRoleModem + default: + if name == "ttyUSB2" { + return PortRoleAT + } + return PortRoleUnknown + } +} + +func selectATPort(ports []Port) Port { + var best Port + bestScore := 0 + for _, port := range ports { + score := 0 + switch { + case port.Name == "ttyUSB2": + score = 120 + case port.Role == PortRoleAT: + score = 100 + case port.InterfaceNumber == 0x04: + score = 90 + case port.InterfaceNumber == 0x05: + score = 40 + case port.Role == PortRoleModem: + score = 30 + } + if score > bestScore { + best, bestScore = port, score + } + } + if bestScore <= 0 { + return Port{} + } + return best +} + +type unsupportedDiscoverer struct{} + +func (unsupportedDiscoverer) Discover(context.Context) ([]Candidate, error) { + return nil, ErrUnsupportedPlatform +} diff --git a/internal/modem/discovery_common_test.go b/internal/modem/discovery_common_test.go new file mode 100644 index 0000000..5ecc785 --- /dev/null +++ b/internal/modem/discovery_common_test.go @@ -0,0 +1,14 @@ +package modem + +import "testing" + +func TestSelectATPortPrefersTTYUSB2AcrossUSBCompositions(t *testing.T) { + ports := []Port{ + {Name: "ttyUSB2", InterfaceNumber: 0x02, Role: PortRoleDiagnostic}, + {Name: "ttyUSB3", InterfaceNumber: 0x05, Role: PortRoleModem}, + } + selected := selectATPort(ports) + if selected.Name != "ttyUSB2" || selected.InterfaceNumber != 0x02 { + t.Fatalf("selected %#v, want ttyUSB2 on interface 02", selected) + } +} diff --git a/internal/modem/discovery_test.go b/internal/modem/discovery_test.go new file mode 100644 index 0000000..05c9423 --- /dev/null +++ b/internal/modem/discovery_test.go @@ -0,0 +1,158 @@ +//go:build linux + +package modem + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strconv" + "testing" +) + +func TestSysFSDiscoverySelectsInterface04AndNeverInterface02(t *testing.T) { + root := t.TempDir() + sysRoot := filepath.Join(root, "sys") + devRoot := filepath.Join(root, "dev") + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "0125\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "manufacturer"), "Android\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "product"), "Android\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "serial"), "Android\n") + + for _, item := range []struct { + interfaceName string + interfaceNumber string + tty string + }{ + {"1-6:1.2", "02", "ttyUSB0"}, + {"1-6:1.3", "03", "ttyUSB1"}, + {"1-6:1.4", "04", "ttyUSB2"}, + {"1-6:1.5", "05", "ttyUSB3"}, + } { + mustWrite( + t, + filepath.Join(usbRoot, item.interfaceName, "bInterfaceNumber"), + item.interfaceNumber+"\n", + ) + mustMkdir(t, filepath.Join( + usbRoot, + item.interfaceName, + item.tty, + "tty", + item.tty, + )) + } + mustMkdir(t, filepath.Join(usbRoot, "1-6:1.0", "net", "enx001122334455")) + mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0")) + + discoverer := NewSysFSDiscoverer(sysRoot, devRoot) + candidates, err := discoverer.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("got %d candidates, want 1", len(candidates)) + } + candidate := candidates[0] + if candidate.ID != "quectel-0125-1-6" { + t.Fatalf("ID = %q", candidate.ID) + } + if candidate.ATPort.Name != "ttyUSB2" { + t.Fatalf("AT port = %#v, want ttyUSB2", candidate.ATPort) + } + if candidate.ATPort.InterfaceNumber != 0x04 { + t.Fatalf("AT interface = %d, want 4", candidate.ATPort.InterfaceNumber) + } + if candidate.Ports[0].Role != PortRoleDiagnostic { + t.Fatalf("interface 02 role = %q", candidate.Ports[0].Role) + } + if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") { + t.Fatalf("QMI control = %q", candidate.QMIControl) + } + if candidate.NetworkInterface != "enx001122334455" { + t.Fatalf("network interface = %q", candidate.NetworkInterface) + } +} + +func TestSysFSDiscoverySelectsTTYUSB2InQMIInterface00Layout(t *testing.T) { + root := t.TempDir() + sysRoot := filepath.Join(root, "sys") + devRoot := filepath.Join(root, "dev") + usbRoot := filepath.Join(sysRoot, "bus", "usb", "devices") + mustWrite(t, filepath.Join(usbRoot, "1-6", "idVendor"), "2c7c\n") + mustWrite(t, filepath.Join(usbRoot, "1-6", "idProduct"), "0125\n") + + for number, tty := range []string{"ttyUSB0", "ttyUSB1", "ttyUSB2", "ttyUSB3"} { + interfaceName := "1-6:1." + strconv.Itoa(number) + mustWrite( + t, + filepath.Join(usbRoot, interfaceName, "bInterfaceNumber"), + fmt.Sprintf("%02x\n", number), + ) + mustMkdir(t, filepath.Join(usbRoot, interfaceName, tty, "tty", tty)) + } + mustWrite( + t, + filepath.Join(usbRoot, "1-6:1.4", "bInterfaceNumber"), + "04\n", + ) + mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "usbmisc", "cdc-wdm0")) + mustMkdir(t, filepath.Join(usbRoot, "1-6:1.4", "net", "wwp0s20f0u6i4")) + + candidates, err := NewSysFSDiscoverer(sysRoot, devRoot).Discover(context.Background()) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("got %d candidates, want 1", len(candidates)) + } + candidate := candidates[0] + if candidate.ATPort.Name != "ttyUSB2" || + candidate.ATPort.InterfaceNumber != 0x02 || + candidate.ATPort.Role != PortRoleAT { + t.Fatalf("AT port = %#v, want ttyUSB2 on interface 02", candidate.ATPort) + } + if candidate.QMIControl != filepath.Join(devRoot, "cdc-wdm0") { + t.Fatalf("QMI control = %q", candidate.QMIControl) + } + if candidate.NetworkInterface != "wwp0s20f0u6i4" { + t.Fatalf("network interface = %q", candidate.NetworkInterface) + } +} + +func TestSysFSDiscoveryIgnoresNonQuectelUSB(t *testing.T) { + root := t.TempDir() + usbRoot := filepath.Join(root, "sys", "bus", "usb", "devices") + mustWrite(t, filepath.Join(usbRoot, "2-1", "idVendor"), "0403\n") + mustWrite(t, filepath.Join(usbRoot, "2-1:1.0", "bInterfaceNumber"), "00\n") + mustMkdir(t, filepath.Join(usbRoot, "2-1:1.0", "ttyUSB9")) + + candidates, err := NewSysFSDiscoverer( + filepath.Join(root, "sys"), + filepath.Join(root, "dev"), + ).Discover(context.Background()) + if err != nil { + t.Fatalf("Discover: %v", err) + } + if len(candidates) != 0 { + t.Fatalf("got %#v, want no candidates", candidates) + } +} + +func mustWrite(t *testing.T, path, value string) { + t.Helper() + mustMkdir(t, filepath.Dir(path)) + if err := os.WriteFile(path, []byte(value), 0o600); err != nil { + t.Fatal(err) + } +} + +func mustMkdir(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o700); err != nil { + t.Fatal(err) + } +} diff --git a/internal/modem/serial.go b/internal/modem/serial.go new file mode 100644 index 0000000..a80a4e6 --- /dev/null +++ b/internal/modem/serial.go @@ -0,0 +1,47 @@ +package modem + +import ( + "context" + "errors" + "fmt" + + "go.bug.st/serial" +) + +type SerialOpener struct { + SessionOptions SessionOptions + BaudRate int +} + +func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + path := port.OpenPath() + if path == "" { + return nil, errors.New("modem: candidate has no AT port") + } + baudRate := opener.BaudRate + if baudRate <= 0 { + baudRate = 115200 + } + rawPort, err := serial.Open(path, &serial.Mode{ + BaudRate: baudRate, + DataBits: 8, + Parity: serial.NoParity, + StopBits: serial.OneStopBit, + }) + if err != nil { + return nil, fmt.Errorf("open AT port %s: %w", path, err) + } + if err := rawPort.ResetInputBuffer(); err != nil { + _ = rawPort.Close() + return nil, fmt.Errorf("reset AT input buffer %s: %w", path, err) + } + session, err := NewSession(rawPort, opener.SessionOptions) + if err != nil { + _ = rawPort.Close() + return nil, err + } + return session, nil +} diff --git a/internal/modem/session.go b/internal/modem/session.go new file mode 100644 index 0000000..d8192cf --- /dev/null +++ b/internal/modem/session.go @@ -0,0 +1,572 @@ +package modem + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + "time" +) + +type Transport interface { + io.ReadWriteCloser + Drain() error + ResetInputBuffer() error + SetReadTimeout(time.Duration) error +} + +type SessionOptions struct { + ReadTimeout time.Duration + CommandTimeout time.Duration + MaxURCs int +} + +func (options SessionOptions) withDefaults() SessionOptions { + if options.ReadTimeout <= 0 { + options.ReadTimeout = 100 * time.Millisecond + } + if options.CommandTimeout <= 0 { + options.CommandTimeout = 3 * time.Second + } + if options.MaxURCs <= 0 { + options.MaxURCs = 256 + } + return options +} + +// Session serializes commands for one physical AT port. Reading is intentionally +// performed under the same mutex as writing: this prevents two callers from +// consuming each other's responses while still allowing interleaved URCs to be +// separated and queued. +type Session struct { + mu sync.Mutex + transport Transport + options SessionOptions + readBuf []byte + urcs []string + closed bool + poisoned bool +} + +// PoisonedClient is implemented by Session. A poisoned session has hit a +// transport-fatal error (a failed write/drain/read or a closed serial line); +// the underlying fd is wedged and every subsequent command reuses the corpse. +// AT-level failures (CommandError, command timeout) do NOT poison — the +// transport is still healthy there, so reopening would only destroy a good +// session over a transient +CME ERROR. +type PoisonedClient interface { + Poisoned() bool +} + +func NewSession(transport Transport, options SessionOptions) (*Session, error) { + if transport == nil { + return nil, errors.New("modem: transport is required") + } + options = options.withDefaults() + if err := transport.SetReadTimeout(options.ReadTimeout); err != nil { + return nil, fmt.Errorf("set serial read timeout: %w", err) + } + return &Session{ + transport: transport, + options: options, + }, nil +} + +// Poisoned reports whether this session has hit a transport-fatal error and +// should be discarded rather than reused. It is safe to call concurrently. +func (session *Session) Poisoned() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.poisoned || session.closed +} + +func (session *Session) Execute(ctx context.Context, command string) (Response, error) { + command, err := normalizeATCommand(command) + if err != nil { + return Response{}, err + } + ctx, cancel := session.commandContext(ctx) + defer cancel() + + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return Response{}, ErrSessionClosed + } + return session.executeLocked(ctx, command) +} + +// ExecutePrompt executes the controlled two-phase AT+CMGS transaction. It does +// not release the session mutex between the command, the '>' prompt, the +// payload terminator, and the final result. +func (session *Session) ExecutePrompt( + ctx context.Context, + command string, + payload []byte, +) (Response, error) { + command, err := normalizeATCommand(command) + if err != nil { + return Response{}, err + } + if !strings.HasPrefix(strings.ToUpper(command), "AT+CMGS=") { + return Response{}, errors.New("modem: prompt command must be AT+CMGS") + } + if len(payload) > 8192 { + return Response{}, errors.New("modem: prompt payload exceeds 8192 bytes") + } + if bytes.IndexByte(payload, 0x1a) >= 0 || bytes.IndexByte(payload, 0x1b) >= 0 { + return Response{}, errors.New("modem: prompt payload contains a terminator") + } + ctx, cancel := session.commandContext(ctx) + defer cancel() + + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return Response{}, ErrSessionClosed + } + return session.executePromptLocked(ctx, command, payload) +} + +func (session *Session) commandContext(ctx context.Context) (context.Context, context.CancelFunc) { + if ctx == nil { + ctx = context.Background() + } + if _, ok := ctx.Deadline(); ok || session.options.CommandTimeout <= 0 { + return context.WithCancel(ctx) + } + return context.WithTimeout(ctx, session.options.CommandTimeout) +} + +func (session *Session) executeLocked(ctx context.Context, command string) (Response, error) { + started := time.Now() + response := Response{Command: command} + if err := ctx.Err(); err != nil { + return response, err + } + if err := writeAll(session.transport, []byte(command+"\r")); err != nil { + session.poisonLocked() + return response, fmt.Errorf("write %s: %w", command, err) + } + if err := session.transport.Drain(); err != nil { + session.poisonLocked() + return response, fmt.Errorf("drain %s: %w", command, err) + } + return session.readFinalLocked(ctx, started, command, "", response) +} + +// poisonLocked marks the session unusable after a transport-fatal error. Held +// under session.mu by the caller; idempotent. +func (session *Session) poisonLocked() { + session.poisoned = true +} + +func (session *Session) executePromptLocked( + ctx context.Context, + command string, + payload []byte, +) (Response, error) { + started := time.Now() + response := Response{Command: command} + if err := ctx.Err(); err != nil { + return response, err + } + if err := writeAll(session.transport, []byte(command+"\r")); err != nil { + session.poisonLocked() + return response, fmt.Errorf("write %s: %w", command, err) + } + if err := session.transport.Drain(); err != nil { + session.poisonLocked() + return response, fmt.Errorf("drain %s: %w", command, err) + } + if err := session.waitPromptLocked(ctx, command, &response); err != nil { + response.Duration = time.Since(started) + return response, session.normalizeReadError(command, err) + } + if err := ctx.Err(); err != nil { + session.abortPromptLocked() + response.Duration = time.Since(started) + return response, err + } + if err := writeAll(session.transport, payload); err != nil { + session.poisonLocked() + session.abortPromptLocked() + response.Duration = time.Since(started) + return response, fmt.Errorf("write %s payload: %w", command, err) + } + if err := writeAll(session.transport, []byte{0x1a}); err != nil { + session.poisonLocked() + session.abortPromptLocked() + response.Duration = time.Since(started) + return response, fmt.Errorf("terminate %s payload: %w", command, err) + } + if err := session.transport.Drain(); err != nil { + session.poisonLocked() + response.Duration = time.Since(started) + return response, fmt.Errorf("drain %s payload: %w", command, err) + } + return session.readFinalLocked(ctx, started, command, string(payload), response) +} + +func (session *Session) readFinalLocked( + ctx context.Context, + started time.Time, + command string, + payloadEcho string, + response Response, +) (Response, error) { + expectedPrefix := expectedResponsePrefix(command) + for { + line, err := session.readLineLocked(ctx) + if err != nil { + response.Duration = time.Since(started) + return response, session.normalizeReadError(command, err) + } + line = strings.TrimSpace(strings.Trim(line, "\x00")) + if line == "" || strings.EqualFold(line, command) || + (payloadEcho != "" && line == payloadEcho) { + continue + } + if isFinalResult(line) { + response.Final = line + response.Duration = time.Since(started) + if response.OK() { + return response, nil + } + return response, &CommandError{ + Command: command, + Final: line, + Lines: append([]string(nil), response.Lines...), + } + } + if isURC(line) && !strings.HasPrefix(strings.ToUpper(line), expectedPrefix) { + response.URCs = append(response.URCs, line) + session.enqueueURCLocked(line) + continue + } + response.Lines = append(response.Lines, line) + } +} + +func (session *Session) waitPromptLocked( + ctx context.Context, + command string, + response *Response, +) error { + expectedPrefix := expectedResponsePrefix(command) + for { + if index := promptIndex(session.readBuf); index >= 0 { + prefix := string(session.readBuf[:index]) + session.readBuf = session.readBuf[index+1:] + for len(session.readBuf) > 0 && + (session.readBuf[0] == ' ' || session.readBuf[0] == '\t') { + session.readBuf = session.readBuf[1:] + } + for _, line := range strings.FieldsFunc(prefix, func(character rune) bool { + return character == '\r' || character == '\n' + }) { + if err := session.consumePromptLineLocked( + command, + expectedPrefix, + line, + response, + ); err != nil { + return err + } + } + return nil + } + if line, ok := popLine(&session.readBuf); ok { + if err := session.consumePromptLineLocked( + command, + expectedPrefix, + line, + response, + ); err != nil { + return err + } + continue + } + if err := ctx.Err(); err != nil { + return err + } + buffer := make([]byte, 1024) + count, err := session.transport.Read(buffer) + if count > 0 { + session.readBuf = append(session.readBuf, buffer[:count]...) + continue + } + if err != nil { + if errors.Is(err, io.EOF) && session.closed { + return ErrSessionClosed + } + session.poisonLocked() + return fmt.Errorf("read serial prompt: %w", err) + } + } +} + +func promptIndex(buffer []byte) int { + for index, character := range buffer { + if character != '>' { + continue + } + if index == 0 || buffer[index-1] == '\r' || buffer[index-1] == '\n' { + return index + } + } + return -1 +} + +func (session *Session) consumePromptLineLocked( + command string, + expectedPrefix string, + line string, + response *Response, +) error { + line = strings.TrimSpace(strings.Trim(line, "\x00")) + if line == "" || strings.EqualFold(line, command) { + return nil + } + if isFinalResult(line) { + response.Final = line + if response.OK() { + return fmt.Errorf("%w: %s", ErrPromptNotReceived, command) + } + return &CommandError{ + Command: command, + Final: line, + Lines: append([]string(nil), response.Lines...), + } + } + if isURC(line) && !strings.HasPrefix(strings.ToUpper(line), expectedPrefix) { + response.URCs = append(response.URCs, line) + session.enqueueURCLocked(line) + return nil + } + response.Lines = append(response.Lines, line) + return nil +} + +func (session *Session) normalizeReadError(command string, err error) error { + if errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) { + _ = session.transport.ResetInputBuffer() + session.readBuf = nil + if errors.Is(err, context.DeadlineExceeded) { + return fmt.Errorf("%w: %s", ErrCommandTimeout, command) + } + } + return err +} + +func (session *Session) abortPromptLocked() { + _ = writeAll(session.transport, []byte{0x1b}) + _ = session.transport.Drain() + _ = session.transport.ResetInputBuffer() + session.readBuf = nil +} + +// WaitURC waits for an unsolicited result matching predicate. Non-matching URCs +// remain queued for another consumer. +func (session *Session) WaitURC( + ctx context.Context, + predicate func(string) bool, +) (string, error) { + if predicate == nil { + return "", errors.New("modem: URC predicate is required") + } + if ctx == nil { + ctx = context.Background() + } + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return "", ErrSessionClosed + } + + for index, line := range session.urcs { + if predicate(line) { + session.urcs = append(session.urcs[:index], session.urcs[index+1:]...) + return line, nil + } + } + for { + line, err := session.readLineLocked(ctx) + if err != nil { + return "", err + } + line = strings.TrimSpace(strings.Trim(line, "\x00")) + if line == "" { + continue + } + if predicate(line) { + return line, nil + } + session.enqueueURCLocked(line) + } +} + +func (session *Session) enqueueURCLocked(line string) { + if len(session.urcs) >= session.options.MaxURCs { + copy(session.urcs, session.urcs[1:]) + session.urcs[len(session.urcs)-1] = line + return + } + session.urcs = append(session.urcs, line) +} + +func (session *Session) readLineLocked(ctx context.Context) (string, error) { + for { + if line, ok := popLine(&session.readBuf); ok { + return line, nil + } + if err := ctx.Err(); err != nil { + return "", err + } + buffer := make([]byte, 1024) + count, err := session.transport.Read(buffer) + if count > 0 { + session.readBuf = append(session.readBuf, buffer[:count]...) + continue + } + if err != nil { + if errors.Is(err, io.EOF) && session.closed { + return "", ErrSessionClosed + } + session.poisonLocked() + return "", fmt.Errorf("read serial response: %w", err) + } + } +} + +func popLine(buffer *[]byte) (string, bool) { + data := *buffer + for index, character := range data { + if character != '\r' && character != '\n' { + continue + } + line := string(data[:index]) + next := index + 1 + for next < len(data) && (data[next] == '\r' || data[next] == '\n') { + next++ + } + *buffer = data[next:] + return line, true + } + return "", false +} + +func normalizeATCommand(command string) (string, error) { + command = strings.TrimSpace(command) + if command == "" { + return "", errors.New("modem: AT command is empty") + } + if len(command) > 512 { + return "", errors.New("modem: AT command exceeds 512 bytes") + } + if strings.ContainsAny(command, "\r\n\x00") { + return "", errors.New("modem: AT command contains a control delimiter") + } + if !strings.HasPrefix(strings.ToUpper(command), "AT") { + return "", errors.New("modem: command must start with AT") + } + return command, nil +} + +func expectedResponsePrefix(command string) string { + upper := strings.ToUpper(strings.TrimSpace(command)) + if strings.HasPrefix(upper, "AT+CUSD=") { + // +CUSD is asynchronous even when it arrives before the command's OK. + return "\x00" + } + body := strings.TrimPrefix(upper, "AT") + if body == "" || body == "I" { + return "\x00" + } + end := len(body) + for index, character := range body { + if character == '?' || character == '=' || character == ',' { + end = index + break + } + } + name := body[:end] + if name == "" { + return "\x00" + } + return name + ":" +} + +func isFinalResult(line string) bool { + upper := strings.ToUpper(strings.TrimSpace(line)) + return upper == "OK" || + upper == "ERROR" || + upper == "NO CARRIER" || + upper == "BUSY" || + upper == "NO ANSWER" || + strings.HasPrefix(upper, "+CME ERROR:") || + strings.HasPrefix(upper, "+CMS ERROR:") +} + +func isURC(line string) bool { + upper := strings.ToUpper(strings.TrimSpace(line)) + if upper == "RING" || + upper == "RDY" || + upper == "CALL READY" || + upper == "SMS READY" || + upper == "PB DONE" { + return true + } + for _, prefix := range []string{ + "+CMTI:", + "+CMT:", + "+CDS:", + "+CREG:", + "+CGREG:", + "+CEREG:", + "+CUSD:", + "+CLIP:", + "+CRING:", + "+QIND:", + "+QIURC:", + "+QSIMSTAT:", + "+QUSIM:", + "+QNWINFO:", + } { + if strings.HasPrefix(upper, prefix) { + return true + } + } + return false +} + +func writeAll(writer io.Writer, payload []byte) error { + for len(payload) > 0 { + count, err := writer.Write(payload) + if err != nil { + return err + } + if count <= 0 { + return io.ErrShortWrite + } + if count > len(payload) { + return io.ErrShortWrite + } + payload = payload[count:] + } + return nil +} + +func (session *Session) Close() error { + session.mu.Lock() + defer session.mu.Unlock() + if session.closed { + return nil + } + session.closed = true + return session.transport.Close() +} diff --git a/internal/modem/session_test.go b/internal/modem/session_test.go new file mode 100644 index 0000000..aec1298 --- /dev/null +++ b/internal/modem/session_test.go @@ -0,0 +1,428 @@ +package modem + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "testing" + "time" +) + +type transportStep struct { + write string + chunks []string +} + +type transcriptTransport struct { + mu sync.Mutex + steps []transportStep + chunks [][]byte + pendingWrite string + pendingChunks []string + readTimeout time.Duration + resetCount int + closed bool + unexpected error + writePartial bool + writeEvents chan string +} + +func (transport *transcriptTransport) Write(payload []byte) (int, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.closed { + return 0, io.ErrClosedPipe + } + if transport.pendingWrite != "" { + if string(payload) != transport.pendingWrite { + transport.unexpected = fmt.Errorf( + "partial write %q, want %q", + payload, + transport.pendingWrite, + ) + return 0, transport.unexpected + } + for _, chunk := range transport.pendingChunks { + transport.chunks = append(transport.chunks, []byte(chunk)) + } + transport.pendingWrite = "" + transport.pendingChunks = nil + return len(payload), nil + } + if len(transport.steps) == 0 { + transport.unexpected = fmt.Errorf("unexpected write %q", payload) + return 0, transport.unexpected + } + step := transport.steps[0] + transport.steps = transport.steps[1:] + if string(payload) != step.write { + transport.unexpected = fmt.Errorf("write %q, want %q", payload, step.write) + return 0, transport.unexpected + } + if transport.writeEvents != nil { + select { + case transport.writeEvents <- string(payload): + default: + } + } + if transport.writePartial && len(payload) > 1 { + transport.writePartial = false + count := len(payload) / 2 + transport.pendingWrite = step.write[count:] + transport.pendingChunks = append([]string(nil), step.chunks...) + return count, nil + } + for _, chunk := range step.chunks { + transport.chunks = append(transport.chunks, []byte(chunk)) + } + return len(payload), nil +} + +func (transport *transcriptTransport) enqueue(chunks ...string) { + transport.mu.Lock() + for _, chunk := range chunks { + transport.chunks = append(transport.chunks, []byte(chunk)) + } + transport.mu.Unlock() +} + +func (transport *transcriptTransport) Read(buffer []byte) (int, error) { + transport.mu.Lock() + if transport.closed { + transport.mu.Unlock() + return 0, io.EOF + } + if len(transport.chunks) > 0 { + chunk := transport.chunks[0] + count := copy(buffer, chunk) + if count == len(chunk) { + transport.chunks = transport.chunks[1:] + } else { + transport.chunks[0] = chunk[count:] + } + transport.mu.Unlock() + return count, nil + } + timeout := transport.readTimeout + transport.mu.Unlock() + if timeout <= 0 || timeout > 2*time.Millisecond { + timeout = time.Millisecond + } + time.Sleep(timeout) + return 0, nil +} + +func (transport *transcriptTransport) Drain() error { return nil } + +func (transport *transcriptTransport) ResetInputBuffer() error { + transport.mu.Lock() + transport.chunks = nil + transport.resetCount++ + transport.mu.Unlock() + return nil +} + +func (transport *transcriptTransport) SetReadTimeout(timeout time.Duration) error { + transport.mu.Lock() + transport.readTimeout = timeout + transport.mu.Unlock() + return nil +} + +func (transport *transcriptTransport) Close() error { + transport.mu.Lock() + transport.closed = true + transport.mu.Unlock() + return nil +} + +func TestSessionSeparatesInterleavedURCs(t *testing.T) { + transport := &transcriptTransport{steps: []transportStep{{ + write: "AT+CSQ\r", + chunks: []string{ + "\r\nAT+CSQ\r\n+CMTI: \"SM\",7\r\n", + "+CSQ: 24,99\r\nOK\r\n", + }, + }}} + session := newTestSession(t, transport) + response, err := session.Execute(context.Background(), "AT+CSQ") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if got := response.Text(); got != "+CSQ: 24,99" { + t.Fatalf("response = %q", got) + } + if len(response.URCs) != 1 || response.URCs[0] != `+CMTI: "SM",7` { + t.Fatalf("URCs = %#v", response.URCs) + } + urc, err := session.WaitURC(context.Background(), func(line string) bool { + return line == `+CMTI: "SM",7` + }) + if err != nil || urc == "" { + t.Fatalf("WaitURC = %q, %v", urc, err) + } +} + +func TestSessionKeepsExpectedRegistrationLineInResponse(t *testing.T) { + transport := &transcriptTransport{steps: []transportStep{{ + write: "AT+CEREG?\r", + chunks: []string{"\r\n+CEREG: 0,5\r\nOK\r\n"}, + }}} + session := newTestSession(t, transport) + response, err := session.Execute(context.Background(), "AT+CEREG?") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if response.Text() != "+CEREG: 0,5" || len(response.URCs) != 0 { + t.Fatalf("response = %#v", response) + } +} + +func TestSessionQueuesCUSDThatArrivesBeforeOK(t *testing.T) { + transport := &transcriptTransport{steps: []transportStep{{ + write: "AT+CUSD=1,\"*100#\",15\r", + chunks: []string{"\r\n+CUSD: 0,\"004F004B\",72\r\nOK\r\n"}, + }}} + session := newTestSession(t, transport) + response, err := session.Execute(context.Background(), `AT+CUSD=1,"*100#",15`) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if len(response.URCs) != 1 { + t.Fatalf("URCs = %#v", response.URCs) + } + urc, err := session.WaitURC(context.Background(), func(line string) bool { + return len(line) >= 6 && line[:6] == "+CUSD:" + }) + if err != nil || urc != `+CUSD: 0,"004F004B",72` { + t.Fatalf("WaitURC = %q, %v", urc, err) + } +} + +func TestSessionReturnsTypedCommandError(t *testing.T) { + transport := &transcriptTransport{steps: []transportStep{{ + write: "AT+CPIN?\r", + chunks: []string{"\r\n+CME ERROR: 10\r\n"}, + }}} + session := newTestSession(t, transport) + _, err := session.Execute(context.Background(), "AT+CPIN?") + var commandErr *CommandError + if !errors.As(err, &commandErr) || commandErr.Final != "+CME ERROR: 10" { + t.Fatalf("error = %#v", err) + } +} + +func TestSessionTimeoutResetsInputAndRejectsCommandInjection(t *testing.T) { + transport := &transcriptTransport{steps: []transportStep{{write: "AT\r"}}} + session, err := NewSession(transport, SessionOptions{ + ReadTimeout: time.Millisecond, + CommandTimeout: 15 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + _, err = session.Execute(context.Background(), "AT") + if !errors.Is(err, ErrCommandTimeout) { + t.Fatalf("error = %v", err) + } + if transport.resetCount != 1 { + t.Fatalf("reset count = %d", transport.resetCount) + } + if _, err := session.Execute(context.Background(), "AT\rAT+CFUN=0"); err == nil { + t.Fatal("expected command delimiter rejection") + } +} + +func TestSessionHandlesPartialWrites(t *testing.T) { + transport := &transcriptTransport{ + writePartial: true, + steps: []transportStep{{ + write: "AT+CSQ\r", + chunks: []string{"\r\n+CSQ: 1,99\r\nOK\r\n"}, + }}, + } + session := newTestSession(t, transport) + response, err := session.Execute(context.Background(), "AT+CSQ") + if err != nil { + t.Fatalf("Execute: %v", err) + } + if response.Text() != "+CSQ: 1,99" { + t.Fatalf("response = %#v", response) + } + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.pendingWrite != "" || len(transport.steps) != 0 || + transport.unexpected != nil { + t.Fatalf( + "unfinished transcript: pending=%q steps=%d err=%v", + transport.pendingWrite, + len(transport.steps), + transport.unexpected, + ) + } +} + +func TestSessionExecutePromptQueuesURCsAndReturnsCMGS(t *testing.T) { + const pdu = "00010005912143F50008044F60597D" + transport := &transcriptTransport{steps: []transportStep{ + { + write: "AT+CMGS=14\r", + chunks: []string{ + "\r\nAT+CMGS=14\r\n+CMTI: \"SM\",7\r\n> ", + }, + }, + {write: pdu}, + { + write: string([]byte{0x1a}), + chunks: []string{ + "\r\n" + pdu + "\r\n+CMGS: 42\r\n", + "+CMTI: \"SM\",8\r\nOK\r\n", + }, + }, + }} + session := newTestSession(t, transport) + response, err := session.ExecutePrompt( + context.Background(), + "AT+CMGS=14", + []byte(pdu), + ) + if err != nil { + t.Fatalf("ExecutePrompt: %v", err) + } + if response.Text() != "+CMGS: 42" || !response.OK() { + t.Fatalf("response = %#v", response) + } + if len(response.URCs) != 2 { + t.Fatalf("URCs = %#v", response.URCs) + } + for _, wanted := range []string{`+CMTI: "SM",7`, `+CMTI: "SM",8`} { + line, waitErr := session.WaitURC( + context.Background(), + func(line string) bool { return line == wanted }, + ) + if waitErr != nil || line != wanted { + t.Fatalf("WaitURC(%q) = %q, %v", wanted, line, waitErr) + } + } +} + +func TestSessionExecutePromptTimeoutDoesNotWritePayload(t *testing.T) { + transport := &transcriptTransport{ + steps: []transportStep{{write: "AT+CMGS=5\r"}}, + } + session, err := NewSession(transport, SessionOptions{ + ReadTimeout: time.Millisecond, + CommandTimeout: 15 * time.Millisecond, + }) + if err != nil { + t.Fatal(err) + } + _, err = session.ExecutePrompt( + context.Background(), + "AT+CMGS=5", + []byte("001122"), + ) + if !errors.Is(err, ErrCommandTimeout) { + t.Fatalf("error = %v", err) + } + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.resetCount != 1 || len(transport.steps) != 0 || + transport.unexpected != nil { + t.Fatalf( + "transport = reset %d, steps %d, error %v", + transport.resetCount, + len(transport.steps), + transport.unexpected, + ) + } +} + +func TestSessionExecutePromptSerializesConcurrentCommand(t *testing.T) { + events := make(chan string, 4) + transport := &transcriptTransport{ + writeEvents: events, + steps: []transportStep{ + {write: "AT+CMGS=\"12345\"\r"}, + {write: "HELLO"}, + { + write: string([]byte{0x1a}), + chunks: []string{"\r\n+CMGS: 9\r\nOK\r\n"}, + }, + { + write: "AT+CSQ\r", + chunks: []string{"\r\n+CSQ: 20,99\r\nOK\r\n"}, + }, + }, + } + session := newTestSession(t, transport) + promptResult := make(chan error, 1) + go func() { + _, err := session.ExecutePrompt( + context.Background(), + `AT+CMGS="12345"`, + []byte("HELLO"), + ) + promptResult <- err + }() + if first := <-events; first != "AT+CMGS=\"12345\"\r" { + t.Fatalf("first write = %q", first) + } + + normalStarted := make(chan struct{}) + normalResult := make(chan error, 1) + go func() { + close(normalStarted) + _, err := session.Execute(context.Background(), "AT+CSQ") + normalResult <- err + }() + <-normalStarted + transport.enqueue("\r\n> ") + + if err := <-promptResult; err != nil { + t.Fatalf("ExecutePrompt: %v", err) + } + if err := <-normalResult; err != nil { + t.Fatalf("concurrent Execute: %v", err) + } + writes := []string{<-events, <-events, <-events} + want := []string{"HELLO", string([]byte{0x1a}), "AT+CSQ\r"} + for index := range want { + if writes[index] != want[index] { + t.Fatalf("write[%d] = %q, want %q", index, writes[index], want[index]) + } + } +} + +func TestSessionExecutePromptRejectsUnsafeInput(t *testing.T) { + transport := &transcriptTransport{} + session := newTestSession(t, transport) + if _, err := session.ExecutePrompt( + context.Background(), + "AT+CSQ", + []byte("payload"), + ); err == nil { + t.Fatal("expected non-CMGS prompt command rejection") + } + if _, err := session.ExecutePrompt( + context.Background(), + "AT+CMGS=1", + []byte{'A', 0x1a}, + ); err == nil { + t.Fatal("expected Ctrl-Z payload rejection") + } +} + +func newTestSession(t *testing.T, transport Transport) *Session { + t.Helper() + session, err := NewSession(transport, SessionOptions{ + ReadTimeout: time.Millisecond, + CommandTimeout: time.Second, + }) + if err != nil { + t.Fatal(err) + } + return session +} diff --git a/internal/modem/types.go b/internal/modem/types.go new file mode 100644 index 0000000..5f32e9d --- /dev/null +++ b/internal/modem/types.go @@ -0,0 +1,119 @@ +package modem + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +var ( + ErrUnsupportedPlatform = errors.New("modem: platform is not supported") + ErrSessionClosed = errors.New("modem: AT session is closed") + ErrCommandTimeout = errors.New("modem: AT command timed out") + ErrPromptNotReceived = errors.New("modem: command completed without a prompt") +) + +// PortRole describes the conventional role of a Quectel USB serial interface. +// The role is a discovery hint; only a successful AT probe proves that a port is +// usable. +type PortRole string + +const ( + PortRoleUnknown PortRole = "unknown" + PortRoleDiagnostic PortRole = "diagnostic" + PortRoleNMEA PortRole = "nmea" + PortRoleAT PortRole = "at" + PortRoleModem PortRole = "modem" +) + +type Port struct { + Path string `json:"path"` + StablePath string `json:"stablePath,omitempty"` + Name string `json:"name"` + InterfaceNumber int `json:"interfaceNumber"` + Role PortRole `json:"role"` +} + +func (p Port) OpenPath() string { + if strings.TrimSpace(p.StablePath) != "" { + return p.StablePath + } + return p.Path +} + +type Candidate struct { + ID string `json:"id"` + VendorID string `json:"vendorId"` + ProductID string `json:"productId"` + Manufacturer string `json:"manufacturer,omitempty"` + Product string `json:"product,omitempty"` + SerialNumber string `json:"serialNumber,omitempty"` + USBPath string `json:"usbPath"` + ATPort Port `json:"atPort"` + Ports []Port `json:"ports"` + QMIControl string `json:"qmiControl,omitempty"` + NetworkInterface string `json:"networkInterface,omitempty"` +} + +func (c Candidate) HasATPort() bool { + return strings.TrimSpace(c.ATPort.OpenPath()) != "" +} + +type Discoverer interface { + Discover(context.Context) ([]Candidate, error) +} + +// Response is the response belonging to exactly one AT command. URCs that +// arrived between the command echo and final result are returned separately and +// also retained by the session's URC queue. +type Response struct { + Command string `json:"command"` + Lines []string `json:"lines"` + Final string `json:"final"` + URCs []string `json:"urcs,omitempty"` + Duration time.Duration `json:"duration"` +} + +func (r Response) Text() string { + return strings.Join(r.Lines, "\n") +} + +func (r Response) OK() bool { + return strings.EqualFold(strings.TrimSpace(r.Final), "OK") +} + +type CommandError struct { + Command string + Final string + Lines []string +} + +func (e *CommandError) Error() string { + detail := strings.TrimSpace(strings.Join(e.Lines, "; ")) + if detail == "" { + return fmt.Sprintf("%s failed: %s", e.Command, e.Final) + } + return fmt.Sprintf("%s failed: %s (%s)", e.Command, e.Final, detail) +} + +// Client is the device layer's narrow AT dependency. Session implements it; +// tests can supply a deterministic transcript without opening hardware. +type Client interface { + Execute(context.Context, string) (Response, error) + WaitURC(context.Context, func(string) bool) (string, error) + Close() error +} + +// PromptClient performs the two-phase interaction used by AT+CMGS. The +// implementation must hold the same command serialization lock while waiting +// for the prompt, writing payload+Ctrl-Z, and reading the final result. +type PromptClient interface { + Client + ExecutePrompt(context.Context, string, []byte) (Response, error) +} + +type Opener interface { + Open(context.Context, Port) (Client, error) +} diff --git a/internal/proxy/probe.go b/internal/proxy/probe.go new file mode 100644 index 0000000..58e1f47 --- /dev/null +++ b/internal/proxy/probe.go @@ -0,0 +1,160 @@ +package proxy + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "strings" + "time" + + "vocat/internal/i18n" +) + +type ProbeResult struct { + Reachable bool `json:"reachable"` + HandshakeOK bool `json:"handshake_ok"` + UDPAssociateOK bool `json:"udp_associate_ok"` + AuthMethod string `json:"auth_method,omitempty"` + RelayAddr string `json:"relay_addr,omitempty"` + Diagnosis string `json:"diagnosis,omitempty"` + Hint string `json:"hint,omitempty"` +} + +func ProbeSOCKS5( + ctx context.Context, + address string, + username string, + password string, + timeout time.Duration, +) (ProbeResult, error) { + address = strings.TrimSpace(address) + if _, _, err := net.SplitHostPort(address); err != nil { + return ProbeResult{}, fmt.Errorf("proxy: upstream address must be host:port: %w", err) + } + if timeout <= 0 { + timeout = 8 * time.Second + } + probeContext, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + connection, err := (&net.Dialer{Timeout: timeout}).DialContext(probeContext, "tcp", address) + if err != nil { + return ProbeResult{ + Diagnosis: "tcp_unreachable", + Hint: i18n.T("检查地址、端口、防火墙与上游代理监听状态。"), + }, err + } + defer connection.Close() + result := ProbeResult{Reachable: true} + _ = connection.SetDeadline(time.Now().Add(timeout)) + + methods := []byte{0} + if username != "" { + methods = append(methods, 2) + } + greeting := append([]byte{5, byte(len(methods))}, methods...) + if _, err := connection.Write(greeting); err != nil { + return result, err + } + methodResponse := make([]byte, 2) + if _, err := io.ReadFull(connection, methodResponse); err != nil { + return result, err + } + if methodResponse[0] != 5 || methodResponse[1] == 0xff { + result.Diagnosis = "no_acceptable_auth" + return result, errors.New("proxy: upstream rejected all SOCKS5 authentication methods") + } + switch methodResponse[1] { + case 0: + result.AuthMethod = "none" + case 2: + result.AuthMethod = "username_password" + if username == "" || len(username) > 255 || len(password) > 255 { + return result, errors.New("proxy: upstream requires username/password authentication") + } + authRequest := []byte{1, byte(len(username))} + authRequest = append(authRequest, []byte(username)...) + authRequest = append(authRequest, byte(len(password))) + authRequest = append(authRequest, []byte(password)...) + if _, err := connection.Write(authRequest); err != nil { + return result, err + } + authResponse := make([]byte, 2) + if _, err := io.ReadFull(connection, authResponse); err != nil { + return result, err + } + if authResponse[0] != 1 || authResponse[1] != 0 { + result.Diagnosis = "authentication_failed" + return result, errors.New("proxy: upstream username/password authentication failed") + } + default: + result.AuthMethod = fmt.Sprintf("method_%d", methodResponse[1]) + return result, errors.New("proxy: upstream selected an unsupported authentication method") + } + result.HandshakeOK = true + + if _, err := connection.Write([]byte{5, 3, 0, 1, 0, 0, 0, 0, 0, 0}); err != nil { + return result, err + } + reader := bufio.NewReader(connection) + header := make([]byte, 4) + if _, err := io.ReadFull(reader, header); err != nil { + return result, err + } + if header[0] != 5 { + return result, errors.New("proxy: invalid UDP ASSOCIATE response version") + } + if header[1] != 0 { + result.Diagnosis = "udp_associate_rejected" + result.Hint = i18n.T("该代理不能承载 ePDG 所需的 UDP;启用上游 SOCKS5 UDP 转发后重试。") + return result, fmt.Errorf("proxy: upstream rejected UDP ASSOCIATE with code %d", header[1]) + } + host, err := readSOCKSAddress(reader, header[3]) + if err != nil { + return result, err + } + portBytes := make([]byte, 2) + if _, err := io.ReadFull(reader, portBytes); err != nil { + return result, err + } + port := int(portBytes[0])<<8 | int(portBytes[1]) + result.UDPAssociateOK = true + result.RelayAddr = net.JoinHostPort(host, fmt.Sprintf("%d", port)) + result.Diagnosis = "ready" + result.Hint = i18n.T("TCP 握手、认证和 UDP ASSOCIATE 均通过。") + return result, nil +} + +func readSOCKSAddress(reader io.Reader, addressType byte) (string, error) { + switch addressType { + case 1: + value := make([]byte, net.IPv4len) + if _, err := io.ReadFull(reader, value); err != nil { + return "", err + } + return net.IP(value).String(), nil + case 3: + var length [1]byte + if _, err := io.ReadFull(reader, length[:]); err != nil { + return "", err + } + if length[0] == 0 { + return "", errors.New("empty SOCKS5 domain") + } + value := make([]byte, int(length[0])) + if _, err := io.ReadFull(reader, value); err != nil { + return "", err + } + return string(value), nil + case 4: + value := make([]byte, net.IPv6len) + if _, err := io.ReadFull(reader, value); err != nil { + return "", err + } + return net.IP(value).String(), nil + default: + return "", errors.New("unsupported SOCKS5 address type") + } +} diff --git a/internal/server/access_control.go b/internal/server/access_control.go new file mode 100644 index 0000000..c37746b --- /dev/null +++ b/internal/server/access_control.go @@ -0,0 +1,262 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/netip" + "strings" + + "vocat/internal/store" +) + +const accessSettingKey = "security.access" + +// accessConfig is the persisted network access policy. +type accessConfig struct { + Mode string `json:"mode"` // "internal" (default) or "public" + AllowedCIDRs []string `json:"allowed_cidrs"` // extra CIDRs always allowed + TrustProxyHeaders bool `json:"trust_proxy_headers"` // honor X-Forwarded-For +} + +// parsedAccessConfig is the validated runtime form of accessConfig. +type parsedAccessConfig struct { + mode string + cidrs []netip.Prefix + trustProxy bool +} + +// internalNetworks are always allowed when mode is "internal": loopback, +// RFC1918 private ranges, link-local, and IPv6 ULA. +var internalNetworks = []netip.Prefix{ + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("::1/128"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("fc00::/7"), +} + +func defaultAccessConfig() parsedAccessConfig { + return parsedAccessConfig{mode: "internal"} +} + +// parseAccessConfig validates and parses a persisted access policy. +func parseAccessConfig(config accessConfig) (parsedAccessConfig, error) { + mode := strings.ToLower(strings.TrimSpace(config.Mode)) + if mode == "" { + mode = "internal" + } + if mode != "internal" && mode != "public" { + return parsedAccessConfig{}, errors.New("mode must be \"internal\" or \"public\"") + } + parsed := parsedAccessConfig{ + mode: mode, + trustProxy: config.TrustProxyHeaders, + } + for _, raw := range config.AllowedCIDRs { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if prefix, err := netip.ParsePrefix(raw); err == nil { + parsed.cidrs = append(parsed.cidrs, prefix.Masked()) + continue + } + if address, err := netip.ParseAddr(raw); err == nil { + bits := 32 + if address.Is6() { + bits = 128 + } + parsed.cidrs = append(parsed.cidrs, netip.PrefixFrom(address, bits)) + continue + } + return parsedAccessConfig{}, errors.New("invalid CIDR or IP: " + raw) + } + return parsed, nil +} + +// allowed reports whether a client address may reach the service. +func (config parsedAccessConfig) allowed(address netip.Addr) bool { + if !address.IsValid() { + return false + } + // Normalize IPv4-mapped IPv6 addresses (e.g. ::ffff:192.168.1.5 seen on + // dual-stack listeners) to their IPv4 form so they match the internal + // ranges below; without this they would be denied even though they are + // ordinary internal IPv4 clients. + address = address.Unmap() + if config.mode == "public" { + return true + } + if address.IsLoopback() { + return true + } + for _, prefix := range internalNetworks { + if prefix.Contains(address) { + return true + } + } + for _, prefix := range config.cidrs { + if prefix.Contains(address) { + return true + } + } + return false +} + +// clientIP determines the request's source address, honoring X-Forwarded-For +// only when the deployment is configured to trust proxy headers. +func (config parsedAccessConfig) clientIP(r *http.Request) netip.Addr { + if config.trustProxy { + if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" { + first := strings.TrimSpace(strings.Split(forwarded, ",")[0]) + if address, err := netip.ParseAddr(first); err == nil { + return address.Unmap() + } + } + if real := strings.TrimSpace(r.Header.Get("X-Real-IP")); real != "" { + if address, err := netip.ParseAddr(real); err == nil { + return address.Unmap() + } + } + } + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err != nil { + host = strings.TrimSpace(r.RemoteAddr) + } + address, err := netip.ParseAddr(host) + if err != nil { + return netip.Addr{} + } + // Report the canonical (unmapped) form so logs, the login rate-limit key, + // and the access decision all agree on one representation of an IPv4 client. + return address.Unmap() +} + +// accessControl rejects requests whose source IP is outside the configured +// access policy. It wraps the whole mux so every route (API, SPA, websheets) is +// protected uniformly. +func (s *Server) accessControl(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.accessMu.RLock() + config := s.access + s.accessMu.RUnlock() + address := config.clientIP(r) + if config.allowed(address) { + next.ServeHTTP(w, r) + return + } + s.logger.Warn( + "request denied by network access policy", + "remote_addr", r.RemoteAddr, + "client_ip", address.String(), + "path", r.URL.Path, + ) + writeError( + w, + http.StatusForbidden, + "network_access_denied", + "access is restricted to internal network addresses", + ) + }) +} + +func (s *Server) currentAccessConfig() parsedAccessConfig { + s.accessMu.RLock() + defer s.accessMu.RUnlock() + return s.access +} + +// loadAccessConfig reads the persisted policy (defaulting to internal) into the +// runtime cache. Called at startup. +func (s *Server) loadAccessConfig(ctx context.Context) { + config := defaultAccessConfig() + setting, err := s.store.AppSetting(ctx, accessSettingKey) + if err == nil { + var stored accessConfig + if json.Unmarshal(setting.Value, &stored) == nil { + if parsed, parseErr := parseAccessConfig(stored); parseErr == nil { + config = parsed + } + } + } else if !errors.Is(err, store.ErrNotFound) { + s.logger.Warn("load access policy failed", "error", err) + } + s.accessMu.Lock() + s.access = config + s.accessMu.Unlock() +} + +// handleSecuritySettings reads and writes the network access policy. +// +// GET /api/settings/security +// PUT /api/settings/security +func (s *Server) handleSecuritySettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + config := s.currentAccessConfig() + address := config.clientIP(r) + cidrs := make([]string, 0, len(config.cidrs)) + for _, prefix := range config.cidrs { + cidrs = append(cidrs, prefix.String()) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "mode": config.mode, + "allowed_cidrs": cidrs, + "trust_proxy_headers": config.trustProxy, + "client_ip": address.String(), + "client_allowed": config.allowed(address), + }, + }) + case http.MethodPut: + var request accessConfig + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + parsed, err := parseAccessConfig(request) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_access_policy", err.Error()) + return + } + payload, err := json.Marshal(request) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + return + } + if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{ + Key: accessSettingKey, + Value: payload, + }); err != nil { + s.writeStoreError(w, err) + return + } + s.accessMu.Lock() + s.access = parsed + s.accessMu.Unlock() + s.audit(r, "settings.security.update", "settings", "security", "success") + address := parsed.clientIP(r) + cidrs := make([]string, 0, len(parsed.cidrs)) + for _, prefix := range parsed.cidrs { + cidrs = append(cidrs, prefix.String()) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "mode": parsed.mode, + "allowed_cidrs": cidrs, + "trust_proxy_headers": parsed.trustProxy, + "client_ip": address.String(), + "client_allowed": parsed.allowed(address), + }, + }) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} diff --git a/internal/server/at_guard_test.go b/internal/server/at_guard_test.go new file mode 100644 index 0000000..773c1d4 --- /dev/null +++ b/internal/server/at_guard_test.go @@ -0,0 +1,46 @@ +package server + +import "testing" + +func TestValidateATCommandBlocksTrafficMessagingAndDialActions(t *testing.T) { + t.Parallel() + for _, command := range []string{ + "AT+CGATT=1", + "AT+CGACT=1,1", + "AT+CGDATA=\"PPP\",1", + "AT+QNETDEVCTL=1,1,1", + "AT+QIACT=1", + "AT+CMGS=42", + "AT+CMSS=7", + "AT+CMGC=12", + "AT+QCMGS=42", + "AT+CUSD=1,\"*100#\"", + "ATD12345;", + "ATA", + "ATH", + "AT+CSQ; +CGACT = 1,1", + "AT+CSQ;+CMSS=7", + "AT+CSQ;D12345;", + } { + if err := validateATCommand(command); err == nil { + t.Errorf("validateATCommand(%q) permitted a guarded mutation", command) + } + } +} + +func TestValidateATCommandAllowsReadOnlyStatusQueries(t *testing.T) { + t.Parallel() + for _, command := range []string{ + "AT", + "AT+CPIN?", + "AT+CGATT?", + "AT+CGACT?", + "AT+CFUN?", + "AT+CIMI", + "AT+CCID", + } { + if err := validateATCommand(command); err != nil { + t.Errorf("validateATCommand(%q): %v", command, err) + } + } +} diff --git a/internal/server/audit.go b/internal/server/audit.go new file mode 100644 index 0000000..a1e15ee --- /dev/null +++ b/internal/server/audit.go @@ -0,0 +1,68 @@ +package server + +import ( + "context" + "net" + "net/http" + "strings" + "time" + + "vocat/internal/store" +) + +// recordAudit writes one security-relevant event to the audit trail. Failures +// are logged but never block the request being audited. +func (s *Server) recordAudit( + ctx context.Context, + actor string, + action string, + entityType string, + entityID string, + outcome string, + remoteAddr string, +) { + if s.store == nil { + return + } + _, err := s.store.AppendAuditEvent(ctx, store.AuditEvent{ + Actor: actor, + Action: action, + EntityType: entityType, + EntityID: entityID, + Outcome: outcome, + RemoteAddr: remoteAddr, + CreatedAt: time.Now().UTC(), + }) + if err != nil { + s.logger.Warn("write audit event failed", "action", action, "error", err) + } +} + +// audit records an event for an already-authenticated request, resolving the +// actor from the session and the source address from the raw connection (proxy +// headers are deliberately not trusted for the audit trail). +func (s *Server) audit(r *http.Request, action string, entityType string, entityID string, outcome string) { + actor := "" + if s.auth != nil { + if cookie, err := r.Cookie(sessionCookieName); err == nil && cookie.Value != "" { + if session, authErr := s.auth.Authenticate(r.Context(), cookie.Value); authErr == nil { + actor = session.Principal.Username + } + } + } + s.recordAudit(r.Context(), actor, action, entityType, entityID, outcome, requestRemoteHost(r)) +} + +// auditAuth records an authentication event where no session exists yet (the +// actor is the username that was attempted). +func (s *Server) auditAuth(r *http.Request, username string, outcome string) { + s.recordAudit(r.Context(), username, "auth.login", "session", username, outcome, requestRemoteHost(r)) +} + +func requestRemoteHost(r *http.Request) string { + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err != nil { + return strings.TrimSpace(r.RemoteAddr) + } + return host +} diff --git a/internal/server/device_api.go b/internal/server/device_api.go new file mode 100644 index 0000000..075ceb8 --- /dev/null +++ b/internal/server/device_api.go @@ -0,0 +1,1517 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "vocat/internal/device" + "vocat/internal/i18n" + "vocat/internal/modem" + "vocat/internal/store" + "vocat/internal/vowifi" + vowifiruntime "vocat/internal/vowifi/runtime" +) + +// DeviceController is the narrow hardware boundary used by the HTTP layer. +// device.Manager implements it; tests can provide a transcript-backed fake. +type DeviceController interface { + Discover(context.Context) ([]device.Device, error) + List() []device.Device + Get(string) (device.Device, error) + Refresh(context.Context, string) (device.Snapshot, error) + ExecuteAT(context.Context, string, string) (modem.Response, error) + Reboot(context.Context, string) error + USSD(context.Context, string, string) (device.USSDResult, error) + ContinueUSSD(context.Context, string, string) (device.USSDResult, error) + CancelUSSD(context.Context, string) error + SetFlight(context.Context, string, bool) (device.FlightResult, error) + SetNetwork(context.Context, string, device.NetworkRequest) (device.NetworkResult, error) + USBNetMode(context.Context, string) (device.USBNetMode, error) + SetUSBNetMode(context.Context, string, int) (device.USBNetMode, error) + SetUSBNetModeByPort(context.Context, string, int) (device.USBNetMode, error) + OperatorSelection(context.Context, string) (device.OperatorSelection, error) + SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error) + ScanOperators(context.Context, string) (device.OperatorScanResult, error) + SendSMS(context.Context, string, string, string) (device.SMSSendResult, error) + ListSMS(context.Context, string) ([]device.SMSMessage, error) + ReadSMS(context.Context, string, int) (device.SMSMessage, error) + DeleteSMS(context.Context, string, int) error + ESIMInventory(context.Context, string) ([]device.EsimInventoryEntry, error) + ESIMListProfiles(context.Context, string) (device.EsimInfo, error) + ESIMSwitchProfile(context.Context, string, string, string) error + ESIMDisableProfile(context.Context, string, string, string) error + ESIMRenameProfile(context.Context, string, string, string, string) error + ESIMDownloadProfile(context.Context, string, device.EsimDownloadParams, func(device.EsimProgress)) (*device.EsimDownloadResult, error) + ESIMDeleteProfile(context.Context, string, string, string) (*device.EsimDeleteResult, error) + ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error) +} + +type deviceConfigPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Interface string `json:"interface"` + ControlDevice string `json:"control_device"` + ATPort string `json:"at_port"` + USBPath string `json:"usb_path"` + AudioDevice string `json:"audio_device"` + ModemIMEI string `json:"modem_imei"` + APN string `json:"apn"` + ProxyPort int `json:"proxy_port"` + BaudRate int `json:"baud_rate"` + DataBits int `json:"data_bits"` + StopBits int `json:"stop_bits"` + Parity string `json:"parity"` + DeviceBackend string `json:"device_backend"` + ESIMTransport string `json:"esim_transport"` + QMIUseProxy bool `json:"qmi_use_proxy"` + QMIProxyPath string `json:"qmi_proxy_path"` + QMIProxyExecutable string `json:"qmi_proxy_executable"` + NetworkEnabled bool `json:"network_enabled"` + SMSEnabled bool `json:"sms_enabled"` + VoWiFiEnabled bool `json:"vowifi_enabled"` +} + +func (payload deviceConfigPayload) toStoreDevice() store.Device { + name := strings.TrimSpace(payload.Name) + if name == "" { + name = payload.ID + } + return store.Device{ + ID: strings.TrimSpace(payload.ID), + Name: name, + Interface: strings.TrimSpace(payload.Interface), + ControlDevice: strings.TrimSpace(payload.ControlDevice), + ATPort: strings.TrimSpace(payload.ATPort), + USBPath: strings.TrimSpace(payload.USBPath), + AudioDevice: strings.TrimSpace(payload.AudioDevice), + ModemIMEI: strings.TrimSpace(payload.ModemIMEI), + APN: strings.TrimSpace(payload.APN), + ProxyPort: payload.ProxyPort, + BaudRate: payload.BaudRate, + DataBits: payload.DataBits, + StopBits: payload.StopBits, + Parity: payload.Parity, + DeviceBackend: payload.DeviceBackend, + ESIMTransport: payload.ESIMTransport, + QMIUseProxy: payload.QMIUseProxy, + QMIProxyPath: strings.TrimSpace(payload.QMIProxyPath), + QMIProxyExecutable: strings.TrimSpace(payload.QMIProxyExecutable), + NetworkEnabled: payload.NetworkEnabled, + SMSEnabled: payload.SMSEnabled, + VoWiFiEnabled: payload.VoWiFiEnabled, + } +} + +func validDeviceID(value string) bool { + value = strings.TrimSpace(value) + if len(value) < 1 || len(value) > 64 { + return false + } + for index, character := range value { + if character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || + (index > 0 && (character == '.' || character == '_' || character == '-')) { + continue + } + return false + } + return true +} + +func (s *Server) routeDeviceAPI(w http.ResponseWriter, r *http.Request) bool { + cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") + switch cleanPath { + case "dashboard/devices": + if !requireMethod(w, r, http.MethodGet) { + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": s.dashboardDevices()}) + return true + case "devices": + return s.handleDevices(w, r) + case "devices/discovered": + return s.handleDiscoveredDevices(w, r) + case "devices/actions/rescan": + return s.handleDeviceRescan(w, r) + case "device-mgmt/discovered/fix-usbnet": + return s.handleFixUSBNet(w, r) + } + + segments := splitAPIPath(cleanPath) + if len(segments) >= 2 && segments[0] == "devices" { + id := segments[1] + if id == "" { + writeError(w, http.StatusBadRequest, "invalid_device", "device ID is empty") + return true + } + return s.handleDevicePath(w, r, id, segments[2:]) + } + return false +} + +func splitAPIPath(value string) []string { + raw := strings.Split(value, "/") + result := make([]string, 0, len(raw)) + for _, segment := range raw { + decoded, err := url.PathUnescape(segment) + if err != nil { + decoded = segment + } + result = append(result, decoded) + } + return result +} + +// maxDeviceLimit 是设备数量的软上限:达到上限后禁止再添加新设备。 +const maxDeviceLimit = 5 + +func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool { + switch r.Method { + case http.MethodGet: + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "device_limit": maxDeviceLimit, + "devices": s.deviceSummaries(), + }, + }) + case http.MethodPost: + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return true + } + var request struct { + Config json.RawMessage `json:"config"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + var payload deviceConfigPayload + if err := json.Unmarshal(request.Config, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid_device_config", "device config must be a valid JSON object") + return true + } + if !validDeviceID(payload.ID) { + writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 letters, digits, dots, underscores, or hyphens") + return true + } + if _, err := s.store.Device(r.Context(), payload.ID); err == nil { + writeError(w, http.StatusConflict, "device_exists", "a device with this ID already exists") + return true + } else if !errors.Is(err, store.ErrNotFound) { + s.writeStoreError(w, err) + return true + } + configured, err := s.store.ListDevices(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return true + } + if len(configured) >= maxDeviceLimit { + writeError(w, http.StatusConflict, "device_limit_reached", i18n.Tf("设备数量已达上限,最多只能添加 %d 台设备", maxDeviceLimit)) + return true + } + devices, err := s.devices.Discover(r.Context()) + if err != nil { + s.writeDeviceError(w, err) + return true + } + selected := findDiscoveredDevice(devices, payload) + if selected == nil { + writeError(w, http.StatusNotFound, "device_not_found", "the selected Linux modem was not discovered") + return true + } + config := payload.toStoreDevice() + fillConfigFromPhysical(&config, *selected) + if err := s.store.UpsertDevice(r.Context(), config); err != nil { + s.writeStoreError(w, err) + return true + } + writeJSON(w, http.StatusCreated, map[string]any{ + "data": map[string]any{ + "status": "created", + "id": config.ID, + "discovery_key": selected.ID, + "physical_device": s.configuredDeviceSummary(config, selected), + }, + }) + default: + w.Header().Set("Allow", "GET, POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } + return true +} + +func findDiscoveredDevice(devices []device.Device, config deviceConfigPayload) *device.Device { + for index := range devices { + candidate := devices[index].Candidate + if config.ATPort != "" && + (candidate.ATPort.Path == config.ATPort || candidate.ATPort.OpenPath() == config.ATPort) { + return &devices[index] + } + if config.ControlDevice != "" && candidate.QMIControl == config.ControlDevice { + return &devices[index] + } + if config.USBPath != "" && candidate.USBPath == config.USBPath { + return &devices[index] + } + } + return nil +} + +func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request) bool { + if !requireMethod(w, r, http.MethodGet) { + return true + } + if s.devices == nil { + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": []any{}}}) + return true + } + devices := s.devices.List() + configured, err := s.store.ListDevices(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return true + } + result := make([]map[string]any, 0, len(devices)) + for _, entry := range devices { + candidate := entry.Candidate + atPorts := make([]string, 0, len(candidate.Ports)) + for _, port := range candidate.Ports { + if port.Role != modem.PortRoleDiagnostic { + atPorts = append(atPorts, port.OpenPath()) + } + } + controlPath := candidate.QMIControl + if controlPath == "" { + controlPath = candidate.ATPort.OpenPath() + } + configuredID := "" + for _, config := range configured { + if physicalMatchesConfig(entry, config) { + configuredID = config.ID + break + } + } + result = append(result, map[string]any{ + "discovery_key": entry.ID, + "control_path": controlPath, + "net_interface": candidate.NetworkInterface, + "usb_path": candidate.USBPath, + "vendor_id": parseHexID(candidate.VendorID), + "product_id": parseHexID(candidate.ProductID), + "driver_name": "", + "at_ports": atPorts, + "at_port": candidate.ATPort.OpenPath(), + "imei": snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMEI }), + "mode": backendMode(candidate), + "network_capable": candidate.NetworkInterface != "" || candidate.QMIControl != "", + "configured": configuredID != "", + "configured_id": configuredID, + "degraded": !candidate.HasATPort(), + }) + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}}) + return true +} + +func parseHexID(value string) int64 { + value = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(value)), "0x") + number, _ := strconv.ParseInt(value, 16, 64) + return number +} + +func (s *Server) handleDeviceRescan(w http.ResponseWriter, r *http.Request) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return true + } + devices, err := s.devices.Discover(r.Context()) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"status": "ok", "devices": len(devices)}, + }) + return true +} + +func (s *Server) handleDevicePath( + w http.ResponseWriter, + r *http.Request, + id string, + tail []string, +) bool { + config, err := s.store.Device(r.Context(), id) + if err != nil { + s.writeStoreError(w, err) + return true + } + if len(tail) == 0 { + switch r.Method { + case http.MethodDelete: + if err := s.store.DeleteDevice(r.Context(), id); err != nil { + s.writeStoreError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"deleted": true, "physical_device_untouched": true}, + }) + case http.MethodPut: + var request struct { + Config json.RawMessage `json:"config"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + var payload deviceConfigPayload + if err := json.Unmarshal(request.Config, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid_device_config", "device config must be a valid JSON object") + return true + } + if payload.ID != "" && payload.ID != id { + writeError(w, http.StatusConflict, "immutable_device_id", "device ID cannot be changed") + return true + } + next := payload.toStoreDevice() + next.ID = id + next.CreatedAt = config.CreatedAt + if next.Name == id && strings.TrimSpace(payload.Name) == "" { + next.Name = config.Name + } + if err := s.store.UpsertDevice(r.Context(), next); err != nil { + s.writeStoreError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"status": "saved", "config": storedDeviceConfig(next)}, + }) + default: + w.Header().Set("Allow", "DELETE, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } + return true + } + + entry, physicalID, physicalPresent := s.physicalForConfig(config) + if len(tail) > 0 && tail[0] == "esim" { + return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent) + } + switch strings.Join(tail, "/") { + case "overview": + if !requireMethod(w, r, http.MethodGet) { + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"devices": []any{s.configuredDeviceOverview(config, entry, physicalPresent)}}, + }) + case "overview/stream": + return s.handleOverviewStream(w, r, config, entry, physicalPresent) + case "status": + if !requireMethod(w, r, http.MethodGet) { + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": s.configuredDeviceStatus(config, entry, physicalPresent)}) + case "config": + if !requireMethod(w, r, http.MethodGet) { + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"config": storedDeviceConfig(config)}, + }) + case "actions/refresh": + if !requireMethod(w, r, http.MethodPost) { + return true + } + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + snapshot, err := s.devices.Refresh(r.Context(), physicalID) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": snapshot}) + case "actions/at": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleAT(w, r, physicalID) + case "actions/ussd": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleUSSD(w, r, physicalID) + case "actions/ussd/continue": + return s.handleUSSDContinue(w, r) + case "actions/ussd/cancel": + return s.handleUSSDCancel(w, r) + case "actions/reboot": + if !requireMethod(w, r, http.MethodPost) { + return true + } + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + if err := s.devices.Reboot(r.Context(), physicalID); err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusAccepted, map[string]any{"data": map[string]any{"status": "rebooting"}}) + case "flight-mode": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleFlightMode(w, r, physicalID) + case "usbnet-mode": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleUSBNetMode(w, r, physicalID) + case "operator_selection": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleOperatorSelection(w, r, physicalID) + case "operator_selection/scan": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleOperatorScan(w, r, physicalID) + case "operator_selection/scan/stream": + if !s.requirePhysicalDevice(w, physicalPresent) { + return true + } + return s.handleOperatorScanStream(w, r, physicalID) + case "vowifi": + return s.handleVoWiFiEnabled(w, r, config, physicalPresent) + case "vowifi/actions/reconnect": + return s.handleVoWiFiReconnect(w, r, config, physicalPresent) + case "vowifi/e911/websheet": + return s.handleE911Websheet(w, r, config) + default: + return false + } + return true +} + +func (s *Server) handleUSBNetMode(w http.ResponseWriter, r *http.Request, physicalID string) bool { + switch r.Method { + case http.MethodGet: + result, err := s.devices.USBNetMode(r.Context(), physicalID) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + case http.MethodPatch: + var request struct { + Mode int `json:"mode"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + result, err := s.devices.SetUSBNetMode(r.Context(), physicalID, request.Mode) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "mode": result.Mode, "name": result.Name, + "reboot_required": true, + }, + }) + default: + w.Header().Set("Allow", "GET, PATCH") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } + return true +} + +// operatorSelectionWire is the current-selection shape the SPA reads. +func operatorSelectionWire(sel device.OperatorSelection) map[string]any { + mode := "automatic" + if sel.Mode != 0 { + mode = "manual" + } + return map[string]any{ + "mode": mode, + "plmn": sel.Operator, + "access_technology": sel.AccessTechnology, + } +} + +// accessTechnologyValue maps a RAT name (as surfaced to the UI by the scan) back +// to its numeric AT+COPS access technology. Returns nil for an unknown name. +func accessTechnologyValue(name string) *int { + var value int + switch strings.ToUpper(strings.TrimSpace(name)) { + case "GSM": + value = 0 + case "UTRAN", "UMTS", "WCDMA": + value = 2 + case "EDGE": + value = 3 + case "HSDPA": + value = 4 + case "HSUPA": + value = 5 + case "HSPA": + value = 6 + case "LTE": + value = 7 + case "NR5G", "NR": + value = 9 + default: + return nil + } + return &value +} + +func (s *Server) handleOperatorSelection(w http.ResponseWriter, r *http.Request, physicalID string) bool { + switch r.Method { + case http.MethodGet: + result, err := s.devices.OperatorSelection(r.Context(), physicalID) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": operatorSelectionWire(result)}) + case http.MethodPost, http.MethodPut, http.MethodPatch: + // The SPA posts {mode, plmn, includes_pcs_digit, rat}; the legacy shape is + // {automatic, plmn, access_technology}. Accept both. includes_pcs_digit is + // accepted for contract compatibility but not currently applied to the command. + var request struct { + Mode string `json:"mode"` + Automatic *bool `json:"automatic"` + PLMN string `json:"plmn"` + AccessTechnology *int `json:"access_technology"` + Rat string `json:"rat"` + IncludesPcsDigit bool `json:"includes_pcs_digit"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + automatic := false + switch strings.ToLower(strings.TrimSpace(request.Mode)) { + case "manual": + automatic = false + case "automatic": + automatic = true + default: + if request.Automatic != nil { + automatic = *request.Automatic + } + } + accessTechnology := request.AccessTechnology + if rat := strings.TrimSpace(request.Rat); rat != "" { + accessTechnology = accessTechnologyValue(rat) + } + // Manual PLMN selection can take tens of seconds while the modem + // searches for and registers on the requested network. The server's + // WriteTimeout would otherwise cut the response off, so clear the + // connection's write deadline for this request like the SSE paths do. + controller := http.NewResponseController(w) + _ = controller.SetWriteDeadline(time.Time{}) + result, err := s.devices.SetOperatorSelection(r.Context(), physicalID, automatic, request.PLMN, accessTechnology) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": operatorSelectionWire(result)}) + default: + w.Header().Set("Allow", "GET, POST, PUT, PATCH") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } + return true +} + +func (s *Server) handleVoWiFiEnabled( + w http.ResponseWriter, + r *http.Request, + config store.Device, + physicalPresent bool, +) bool { + if !requireMethod(w, r, http.MethodPatch) { + return true + } + var request struct { + Enabled bool `json:"enabled"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + if s.vowifi == nil { + writeError(w, http.StatusServiceUnavailable, "vowifi_provider_unavailable", "the VoWiFi runtime is unavailable") + return true + } + if request.Enabled && !physicalPresent { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return true + } + if request.Enabled { + entry, _, _ := s.physicalForConfig(config) + imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI }) + if reason := device.RegionBlockReason(imsi); reason != "" { + writeError(w, http.StatusForbidden, "region_blocked", reason) + return true + } + } + + previous := config.VoWiFiEnabled + config.VoWiFiEnabled = request.Enabled + if err := s.store.UpsertDevice(r.Context(), config); err != nil { + s.writeStoreError(w, err) + return true + } + state, err := s.vowifi.RequestEnabled(config.ID, request.Enabled) + if err != nil { + // Repeating the same desired state while its asynchronous transaction is + // already running is idempotent. Treat it as accepted instead of making a + // harmless double-click (or a stale browser refresh) surface vowifi_busy. + if errors.Is(err, vowifiruntime.ErrOperationInProgress) && + (state.Enabled == request.Enabled || previous == request.Enabled) { + writeJSON(w, http.StatusAccepted, map[string]any{ + "data": map[string]any{ + "accepted": true, + "enabled": request.Enabled, + "status": "in_progress", + "runtime": state, + }, + }) + return true + } + config.VoWiFiEnabled = previous + if restoreErr := s.store.UpsertDevice(r.Context(), config); restoreErr != nil { + s.logger.Error( + "restore VoWiFi policy after rejected runtime operation", + "device_id", config.ID, + "error", restoreErr, + ) + } + s.writeVoWiFiError(w, err) + return true + } + writeJSON(w, http.StatusAccepted, map[string]any{ + "data": map[string]any{ + "accepted": true, + "enabled": request.Enabled, + "status": map[bool]string{true: "starting", false: "stopping"}[request.Enabled], + "runtime": state, + }, + }) + return true +} + +func (s *Server) handleVoWiFiReconnect( + w http.ResponseWriter, + r *http.Request, + config store.Device, + physicalPresent bool, +) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + if s.vowifi == nil { + writeError(w, http.StatusServiceUnavailable, "vowifi_provider_unavailable", "the VoWiFi runtime is unavailable") + return true + } + if !config.VoWiFiEnabled { + writeError(w, http.StatusConflict, "vowifi_disabled", "enable VoWiFi before requesting a reconnect") + return true + } + if !physicalPresent { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return true + } + state, err := s.vowifi.RequestReconnect(config.ID) + if err != nil { + s.writeVoWiFiError(w, err) + return true + } + writeJSON(w, http.StatusAccepted, map[string]any{ + "data": map[string]any{ + "accepted": true, + "status": "reconnecting", + "runtime": state, + }, + }) + return true +} + +func (s *Server) writeVoWiFiError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, vowifiruntime.ErrNotRegistered): + writeError(w, http.StatusServiceUnavailable, "vowifi_device_unavailable", "the configured device has no VoWiFi runtime") + case errors.Is(err, vowifiruntime.ErrOperationInProgress): + writeError(w, http.StatusConflict, "vowifi_busy", "another VoWiFi operation is still in progress") + case errors.Is(err, vowifiruntime.ErrClosed): + writeError(w, http.StatusServiceUnavailable, "vowifi_runtime_stopped", "the VoWiFi runtime is stopping") + case errors.Is(err, vowifi.ErrNotRunning): + writeError(w, http.StatusConflict, "vowifi_not_running", "VoWiFi is not running") + default: + s.logger.Warn("VoWiFi action rejected", "error", err) + writeError(w, http.StatusBadGateway, "vowifi_error", err.Error()) + } +} + +// maxActionTimeoutMs bounds a client-supplied per-request timeout so a terminal +// action cannot hold the device operation lock indefinitely. +const maxActionTimeoutMs = 120000 + +// actionRequestContext applies the frontend's optional per-request timeout +// (timeout_ms) to the request context. Manager.withTimeout preserves an +// existing deadline, so setting one here makes the requested timeout take +// effect; a non-positive or absent value leaves the manager's defaults in place. +func actionRequestContext(parent context.Context, timeoutMs int) (context.Context, context.CancelFunc) { + if parent == nil { + parent = context.Background() + } + if timeoutMs <= 0 { + return context.WithCancel(parent) + } + if timeoutMs > maxActionTimeoutMs { + timeoutMs = maxActionTimeoutMs + } + return context.WithTimeout(parent, time.Duration(timeoutMs)*time.Millisecond) +} + +func (s *Server) handleAT(w http.ResponseWriter, r *http.Request, id string) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + var request struct { + Command string `json:"cmd"` + TimeoutMs int `json:"timeout_ms"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + command := strings.TrimSpace(request.Command) + if err := validateATCommand(command); err != nil { + writeError(w, http.StatusBadRequest, "unsafe_at_command", err.Error()) + return true + } + ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs) + defer cancel() + response, err := s.devices.ExecuteAT(ctx, id, command) + if err != nil { + s.writeDeviceError(w, err) + return true + } + text := response.Text() + if response.Final != "" { + if text != "" { + text += "\n" + } + text += response.Final + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "response": text, + "final": response.Final, + "duration_ms": response.Duration.Milliseconds(), + "urcs": response.URCs, + }, + }) + return true +} + +func validateATCommand(command string) error { + upper := strings.ToUpper(command) + if len(command) < 2 || len(command) > 512 || !strings.HasPrefix(upper, "AT") { + return errors.New("AT command must start with AT and contain at most 512 characters") + } + if strings.ContainsAny(command, "\r\n\x00") { + return errors.New("AT command must contain exactly one line") + } + canonical := strings.NewReplacer(" ", "", "\t", "").Replace(upper) + for _, blocked := range []string{ + `+QCFG="USBNET"`, + `+QCFG="USBCFG"`, + "+QPOWD", + "+CFUN=", + "+CGATT=", + "+CGACT=", + "+CGDATA", + "+QNETDEVCTL=", + "+QIACT", + "+QIDEACT", + "+CMGS", + "+CMSS", + "+CMGC", + "+QCMGS", + "+CUSD=", + "D", + "A", + "H", + } { + for _, segment := range strings.Split(canonical[2:], ";") { + if strings.HasPrefix(segment, blocked) { + return fmt.Errorf("AT%s is reserved for a guarded device action", blocked) + } + } + } + return nil +} + +func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request, id string) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + var request struct { + Command string `json:"command"` + TimeoutMs int `json:"timeout_ms"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs) + defer cancel() + result, err := s.devices.USSD(ctx, id, request.Command) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "result": result.Text, + "raw": result.Raw, + "dcs": result.DCS, + }, + }) + return true +} + +func (s *Server) handleFlightMode(w http.ResponseWriter, r *http.Request, id string) bool { + if !requireMethod(w, r, http.MethodPatch) { + return true + } + var request struct { + Enabled bool `json:"enabled"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + result, err := s.devices.SetFlight(r.Context(), id, request.Enabled) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + return true +} + +func (s *Server) requirePhysicalDevice(w http.ResponseWriter, present bool) bool { + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return false + } + if !present { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return false + } + return true +} + +func (s *Server) writeDeviceError(w http.ResponseWriter, err error) { + switch { + case errors.Is(err, device.ErrNotFound): + writeError(w, http.StatusNotFound, "device_not_found", "device was not found or is no longer present") + case errors.Is(err, device.ErrNotStarted): + writeError(w, http.StatusServiceUnavailable, "device_manager_not_started", "device manager is not started") + case errors.Is(err, device.ErrNoATPort): + writeError(w, http.StatusServiceUnavailable, "at_port_unavailable", "device has no usable AT port") + case errors.Is(err, device.ErrDataBackendUnavailable): + writeError(w, http.StatusNotImplemented, "data_backend_unavailable", err.Error()) + case errors.Is(err, device.ErrEUICCChannelStuck): + writeError(w, http.StatusServiceUnavailable, "euicc_channel_stuck", err.Error()) + case errors.Is(err, device.ErrESIMDeleteProfileNotFound): + writeError(w, http.StatusNotFound, "esim_profile_not_found", "The profile is no longer present on this eUICC. Refresh the profile list and try again.") + case errors.Is(err, device.ErrESIMDeleteProfileNotDisabled): + writeError(w, http.StatusConflict, "esim_profile_enabled", "The active profile cannot be deleted. Enable another profile first, then delete this disabled profile.") + case errors.Is(err, device.ErrESIMDeleteDisallowedByPolicy): + writeError(w, http.StatusConflict, "esim_delete_disallowed_by_policy", "This profile's policy does not allow it to be deleted.") + case errors.Is(err, device.ErrESIMNicknameTooLong): + writeError(w, http.StatusBadRequest, "esim_nickname_too_long", "Profile nickname must not exceed 64 characters.") + case errors.Is(err, device.ErrESIMNicknameProfileNotFound): + writeError(w, http.StatusNotFound, "esim_profile_not_found", "The profile is no longer present on this eUICC. Refresh the profile list and try again.") + case errors.Is(err, device.ErrESIMDisableProfileNotFound): + writeError(w, http.StatusNotFound, "esim_profile_not_found", "The profile is no longer present on this eUICC. Refresh the profile list and try again.") + case errors.Is(err, device.ErrESIMProfileNotEnabled): + writeError(w, http.StatusConflict, "esim_profile_not_enabled", "This profile is already disabled. Refresh the profile list before retrying.") + case errors.Is(err, device.ErrESIMDisableDisallowedByPolicy): + writeError(w, http.StatusConflict, "esim_disable_disallowed_by_policy", "This profile's policy does not allow it to be disabled.") + case errors.Is(err, device.ErrESIMDisableCATBusy): + writeError(w, http.StatusConflict, "esim_cat_busy", "The eUICC is busy with a SIM Toolkit operation. Wait a moment and retry disabling the profile.") + case errors.Is(err, device.ErrInvalidNetworkAPN): + writeError(w, http.StatusBadRequest, "invalid_apn", "APN must contain only letters, digits, dots, underscores, or hyphens") + case errors.Is(err, device.ErrRegionBlocked): + writeError(w, http.StatusForbidden, "region_blocked", err.Error()) + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, modem.ErrCommandTimeout): + writeError(w, http.StatusGatewayTimeout, "modem_timeout", "the modem did not answer before the command timeout") + case errors.Is(err, context.Canceled): + writeError(w, http.StatusRequestTimeout, "request_canceled", "the modem request was canceled") + default: + s.logger.Warn("device operation failed", "error", err) + writeError(w, http.StatusBadGateway, "modem_error", err.Error()) + } +} + +func (s *Server) deviceSummaries() []map[string]any { + configs, err := s.store.ListDevices(context.Background()) + if err != nil { + s.logger.Error("list configured devices", "error", err) + return []map[string]any{} + } + result := make([]map[string]any, 0, len(configs)) + for _, config := range configs { + entry, _, present := s.physicalForConfig(config) + if present { + result = append(result, s.configuredDeviceSummary(config, &entry)) + } else { + result = append(result, s.configuredDeviceSummary(config, nil)) + } + } + return result +} + +func (s *Server) dashboardDevices() []map[string]any { + devices := s.deviceSummaries() + result := make([]map[string]any, 0, len(devices)) + for _, entry := range devices { + modemStatus, _ := entry["modem"].(map[string]any) + runtime, _ := entry["vowifi_runtime"].(map[string]any) + vowifiActive, _ := runtime["tunnel_ready"].(bool) + result = append(result, map[string]any{ + "id": entry["id"], + "name": entry["name"], + "interface": entry["interface"], + "proxy_port": entry["proxy_port"], + "public_ip": entry["public_ip"], + "healthy": entry["healthy"], + "operator": modemStatus["operator"], + "signal_dbm": modemStatus["signal_dbm"], + "network_mode": modemStatus["network_mode"], + "network_duplex": modemStatus["network_duplex"], + "vowifi_active": vowifiActive, + "vowifi_runtime": runtime, + "network_connected": false, + "model": modemStatus["model"], + }) + } + return result +} + +func (s *Server) physicalForConfig(config store.Device) (device.Device, string, bool) { + if s.devices == nil { + return device.Device{ID: config.ID}, "", false + } + if entry, err := s.devices.Get(config.ID); err == nil && entry.Discovered { + return entry, entry.ID, true + } + for _, entry := range s.devices.List() { + if entry.Discovered && physicalMatchesConfig(entry, config) { + return entry, entry.ID, true + } + } + return device.Device{ID: config.ID}, "", false +} + +func physicalMatchesConfig(entry device.Device, config store.Device) bool { + candidate := entry.Candidate + if entry.ID == config.ID { + return true + } + if config.ATPort != "" && + (config.ATPort == candidate.ATPort.Path || config.ATPort == candidate.ATPort.OpenPath()) { + return true + } + if config.ControlDevice != "" && config.ControlDevice == candidate.QMIControl { + return true + } + if config.USBPath != "" && config.USBPath == candidate.USBPath { + return true + } + return config.ModemIMEI != "" && + entry.Snapshot != nil && + config.ModemIMEI == entry.Snapshot.IMEI +} + +func (s *Server) configuredDeviceSummary( + config store.Device, + entry *device.Device, +) map[string]any { + var result map[string]any + if entry != nil { + result = deviceSummary(*entry) + } else { + result = deviceSummary(device.Device{ID: config.ID}) + } + result["id"] = config.ID + result["name"] = config.Name + result["interface"] = config.Interface + result["proxy_port"] = config.ProxyPort + result["esim_transport"] = config.ESIMTransport + result["sms_enabled"] = config.SMSEnabled + result["network_enabled"] = false + result["vowifi_enabled"] = config.VoWiFiEnabled + if runtime, err := s.store.VoWiFiRuntime(context.Background(), config.ID); err == nil { + runtimeResponse := storedVoWiFiRuntime(runtime) + result["vowifi_runtime"] = runtimeResponse + result["vowifi_active"] = runtime.TunnelReady + if runtime.LocalPhone != "" { + // The SIM panel reads the top-level local_phone; keep modem.phone_number + // in sync for the summary/overview consumers that read it there. + result["local_phone"] = runtime.LocalPhone + result["phone_number_source"] = runtime.PhoneNumberSource + if modemStatus, ok := result["modem"].(map[string]any); ok { + modemStatus["phone_number"] = runtime.LocalPhone + modemStatus["phone_number_source"] = runtime.PhoneNumberSource + } + } + } + return result +} + +func (s *Server) configuredDeviceOverview( + config store.Device, + entry device.Device, + present bool, +) map[string]any { + var physical *device.Device + if present { + physical = &entry + } + result := s.configuredDeviceSummary(config, physical) + result["id"] = config.ID + result["name"] = config.Name + result["interface"] = config.Interface + result["at_port"] = config.ATPort + result["audio_device"] = config.AudioDevice + result["backend_mode"] = config.DeviceBackend + result["control_device"] = config.ControlDevice + result["esim_transport"] = config.ESIMTransport + result["sms_enabled"] = config.SMSEnabled + result["network_enabled"] = false + result["vowifi_enabled"] = config.VoWiFiEnabled + result["radio_live_ok"] = present && entry.Snapshot != nil && entry.Snapshot.Responsive + result["traffic"] = map[string]string{} + result["traffic_raw"] = map[string]int64{} + result["traffic_meta"] = map[string]any{} + return result +} + +func (s *Server) configuredDeviceStatus( + config store.Device, + entry device.Device, + present bool, +) map[string]any { + var physical *device.Device + if present { + physical = &entry + } + summary := s.configuredDeviceSummary(config, physical) + lastUpdated := time.Time{} + if physical != nil { + lastUpdated = physical.LastUpdated + } + result := map[string]any{ + "healthy": summary["healthy"], + "public_ip": summary["public_ip"], + "network_connected": false, + "modem": summary["modem"], + "vowifi": summary["vowifi_runtime"], + "sim_service_table": map[string]any{}, + "pnn": []any{}, + "opl": []any{}, + "last_hardware_refresh": lastUpdated, + } + result["id"] = config.ID + result["name"] = config.Name + result["interface"] = config.Interface + result["proxy_port"] = config.ProxyPort + return result +} + +func storedVoWiFiRuntime(runtime store.VoWiFiRuntime) map[string]any { + return map[string]any{ + "device_id": runtime.DeviceID, + "phase": runtime.Phase, + "dataplane_mode": runtime.DataplaneMode, + "iccid": runtime.ICCID, + "imsi": runtime.IMSI, + "sim_ready": runtime.SIMReady, + "access_ready": runtime.AccessReady, + "tunnel_ready": runtime.TunnelReady, + "ims_ready": runtime.IMSReady, + "sms_ready": runtime.SMSReady, + "reg_status": runtime.RegStatus, + "reg_status_text": runtime.RegStatusText, + "network_mode": runtime.NetworkMode, + "local_phone": runtime.LocalPhone, + "phone_number_source": runtime.PhoneNumberSource, + "last_error_class": runtime.LastErrorClass, + "last_error": runtime.LastError, + "last_reason": runtime.LastReason, + "updated_at": runtime.UpdatedAt, + "tunnel": rawJSONObject(runtime.Tunnel), + "imscore": rawJSONObject(runtime.IMSCore), + "smsip": rawJSONObject(runtime.SMSIP), + } +} + +func rawJSONObject(value json.RawMessage) any { + var result any + if len(value) != 0 && json.Unmarshal(value, &result) == nil { + return result + } + return map[string]any{} +} + +func deviceSummary(entry device.Device) map[string]any { + snapshot := entry.Snapshot + healthy := entry.Discovered && snapshot != nil && snapshot.Responsive && entry.LastError == "" + phone := "" + phoneSource := "" + if snapshot != nil { + phone = snapshot.Phone.Number + phoneSource = snapshot.Phone.Source + } + mode := 0 + modeKnown := false + if snapshot != nil { + mode = snapshot.OperatingMode + modeKnown = snapshot.ModeKnown + } + runtime := idleVoWiFiRuntime(entry.ID, snapshot) + summary := modemSummary(snapshot, phone, phoneSource) + if model, _ := summary["model"].(string); strings.TrimSpace(model) == "" { + if p := strings.TrimSpace(entry.Candidate.Product); p != "" { + summary["model"] = p + } else if id := strings.TrimSpace(entry.ID); id != "" { + summary["model"] = id + } + } + return map[string]any{ + "id": entry.ID, + "name": deviceName(entry), + "running": entry.Discovered, + "healthy": healthy, + "control_online": healthy, + "physical_present": entry.Discovered, + "worker_running": entry.Discovered, + "data_connected": false, + "radio_registered": snapshot != nil && snapshot.OperatorName != "", + "lifecycle_phase": lifecyclePhase(entry), + "lifecycle_reason": entry.LastError, + "public_ip": "", + "private_ip": "", + "interface": entry.Candidate.NetworkInterface, + "esim_transport": backendMode(entry.Candidate), + "sms_enabled": true, + "network_enabled": false, + "vowifi_enabled": false, + "vowifi_active": false, + "vowifi_runtime": runtime, + "modem": summary, + "local_phone": phone, + "phone_number_source": phoneSource, + "network_connected": false, + "registration_state_label": registrationLabel(snapshot), + "flight_mode": modeKnown && (mode == 0 || mode == 4), + } +} + +func deviceOverview(entry device.Device) map[string]any { + result := deviceSummary(entry) + result["at_port"] = entry.Candidate.ATPort.OpenPath() + result["audio_device"] = "" + result["backend_mode"] = backendMode(entry.Candidate) + result["control_device"] = firstNonEmpty(entry.Candidate.QMIControl, entry.Candidate.ATPort.OpenPath()) + result["radio_live_ok"] = entry.Snapshot != nil && entry.Snapshot.Responsive + result["traffic"] = map[string]string{} + result["traffic_raw"] = map[string]int64{} + result["traffic_meta"] = map[string]any{} + return result +} + +func deviceStatus(entry device.Device) map[string]any { + summary := deviceSummary(entry) + return map[string]any{ + "id": entry.ID, + "name": summary["name"], + "healthy": summary["healthy"], + "interface": summary["interface"], + "public_ip": "", + "proxy_port": 1080, + "network_connected": false, + "modem": summary["modem"], + "vowifi": summary["vowifi_runtime"], + "sim_service_table": map[string]any{}, + "pnn": []any{}, + "opl": []any{}, + "last_hardware_refresh": entry.LastUpdated, + } +} + +func storedDeviceConfig(config store.Device) map[string]any { + return map[string]any{ + "id": config.ID, + "name": config.Name, + "interface": config.Interface, + "control_device": config.ControlDevice, + "at_port": config.ATPort, + "usb_path": config.USBPath, + "audio_device": config.AudioDevice, + "modem_imei": config.ModemIMEI, + "apn": config.APN, + "proxy_port": config.ProxyPort, + "baud_rate": config.BaudRate, + "data_bits": config.DataBits, + "stop_bits": config.StopBits, + "parity": config.Parity, + "device_backend": config.DeviceBackend, + "esim_transport": config.ESIMTransport, + "qmi_use_proxy": config.QMIUseProxy, + "qmi_proxy_path": config.QMIProxyPath, + "qmi_proxy_executable": config.QMIProxyExecutable, + "network_enabled": false, + "sms_enabled": config.SMSEnabled, + "vowifi_enabled": config.VoWiFiEnabled, + } +} + +func fillConfigFromPhysical(config *store.Device, entry device.Device) { + candidate := entry.Candidate + if config.Interface == "" { + config.Interface = candidate.NetworkInterface + } + if config.ControlDevice == "" { + config.ControlDevice = firstNonEmpty(candidate.QMIControl, candidate.ATPort.OpenPath()) + } + if config.ATPort == "" { + config.ATPort = candidate.ATPort.OpenPath() + } + if config.USBPath == "" { + config.USBPath = candidate.USBPath + } + if config.ModemIMEI == "" && entry.Snapshot != nil { + config.ModemIMEI = entry.Snapshot.IMEI + } + if config.DeviceBackend == "" { + config.DeviceBackend = backendMode(candidate) + } + if config.ESIMTransport == "" { + config.ESIMTransport = config.DeviceBackend + } +} + +func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) map[string]any { + if snapshot == nil { + return map[string]any{ + "operator": "", + "native_mcc": "", + "native_mnc": "", + "card_mcc": "", + "card_mnc": "", + "card_country": "", + "service_blocked": false, + "blocked_reason": "", + "network_mode": "", + "radio_band": "", + "radio_channel": 0, + "signal_dbm": 0, + "signal_sinr": 0, + "imei": "", + "iccid": "", + "reg_status": 0, + "reg_status_text": "not refreshed", + "sim_inserted": false, + "phone_number": phone, + "phone_number_source": phoneSource, + "model": "", + } + } + mcc, mnc := splitPLMN(snapshot.OperatorCode) + cardMCC, cardMNC := device.CardMCCMNC(snapshot.IMSI) + blockedReason := device.RegionBlockReason(snapshot.IMSI) + return map[string]any{ + "operator": snapshot.OperatorName, + "native_mcc": mcc, + "native_mnc": mnc, + "card_mcc": cardMCC, + "card_mnc": cardMNC, + "card_country": countryNameForMCC(cardMCC), + "service_blocked": blockedReason != "", + "blocked_reason": blockedReason, + "network_mode": snapshot.AccessTech, + "network_duplex": "", + "radio_band": snapshot.Band, + "radio_channel": parseDecimal(snapshot.Channel), + "signal_dbm": pointerInt(snapshot.RSSIDBm), + "signal_rsrp": pointerInt(snapshot.RSRP), + "signal_rsrq": pointerInt(snapshot.RSRQ), + "signal_sinr": pointerInt(snapshot.SINR), + "imei": snapshot.IMEI, + "iccid": snapshot.ICCID, + "imsi": snapshot.IMSI, + "firmware": snapshot.Firmware, + "model": snapshot.Model, + "reg_status": boolInt(snapshot.OperatorName != ""), + "reg_status_text": registrationText(snapshot), + "ps_attached": false, + "sim_inserted": snapshot.SIMStatus != "", + "operating_mode": snapshot.OperatingMode, + "phone_number": phone, + "phone_number_source": phoneSource, + } +} + +func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any { + iccid := "" + imsi := "" + phone := "" + source := "" + simReady := false + if snapshot != nil { + iccid = snapshot.ICCID + imsi = snapshot.IMSI + phone = snapshot.Phone.Number + source = snapshot.Phone.Source + simReady = snapshot.SIMReady + } + return map[string]any{ + "device_id": id, + "phase": "idle", + "dataplane_mode": "", + "iccid": iccid, + "imsi": imsi, + "sim_ready": simReady, + "access_ready": false, + "tunnel_ready": false, + "ims_ready": false, + "sms_ready": false, + "reg_status": 0, + "reg_status_text": "not started", + "network_mode": "", + "local_phone": phone, + "phone_number_source": source, + "last_error_class": "", + "last_error": "", + "last_reason": "disabled", + "updated_at": time.Now().UTC(), + } +} + +func deviceName(entry device.Device) string { + if entry.Snapshot != nil && strings.TrimSpace(entry.Snapshot.Model) != "" { + return entry.Snapshot.Model + } + if strings.TrimSpace(entry.Candidate.Product) != "" { + return entry.Candidate.Product + } + return entry.ID +} + +func backendMode(candidate modem.Candidate) string { + if candidate.QMIControl != "" { + return "qmi" + } + return "at" +} + +func lifecyclePhase(entry device.Device) string { + switch { + case !entry.Discovered: + return "missing" + case entry.LastError != "": + return "degraded" + case entry.Snapshot == nil: + return "discovered" + case entry.Snapshot.Responsive: + return "ready" + default: + return "unresponsive" + } +} + +func registrationLabel(snapshot *device.Snapshot) string { + if snapshot == nil || snapshot.OperatorName == "" { + return "unknown" + } + return "registered" +} + +func registrationText(snapshot *device.Snapshot) string { + if snapshot.OperatorName != "" { + return "registered" + } + return "unknown" +} + +func splitPLMN(value string) (string, string) { + value = strings.TrimSpace(value) + if len(value) < 5 { + return "", "" + } + return value[:3], value[3:] +} + +func parseDecimal(value string) int { + number, _ := strconv.Atoi(strings.TrimSpace(value)) + return number +} + +func pointerInt(value *int) int { + if value == nil { + return 0 + } + return *value +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func snapshotString( + snapshot *device.Snapshot, + selector func(*device.Snapshot) string, +) string { + if snapshot == nil { + return "" + } + return selector(snapshot) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/server/device_features_api.go b/internal/server/device_features_api.go new file mode 100644 index 0000000..83b3702 --- /dev/null +++ b/internal/server/device_features_api.go @@ -0,0 +1,253 @@ +package server + +import ( + "encoding/json" + "fmt" + "net/http" + "time" + + "vocat/internal/device" + "vocat/internal/store" +) + +// beginSSE prepares a response for Server-Sent Events and returns its response +// controller for explicit flushes. +func beginSSE(w http.ResponseWriter) *http.ResponseController { + controller := http.NewResponseController(w) + _ = controller.SetWriteDeadline(time.Time{}) + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-store") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + return controller +} + +func writeSSEEvent(w http.ResponseWriter, controller *http.ResponseController, event string, data any) error { + payload, err := json.Marshal(data) + if err != nil { + return err + } + if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, payload); err != nil { + return err + } + return controller.Flush() +} + +// handleOverviewStream pushes the device's overview as SSE events so the UI can +// watch link/SIM/VoWiFi state change live instead of polling. +func (s *Server) handleOverviewStream( + w http.ResponseWriter, + r *http.Request, + config store.Device, + entry device.Device, + physicalPresent bool, +) bool { + if !requireMethod(w, r, http.MethodGet) { + return true + } + controller := beginSSE(w) + if err := writeSSEEvent(w, controller, "connected", map[string]any{}); err != nil { + return true + } + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + select { + case <-r.Context().Done(): + return true + case <-ticker.C: + currentEntry, _, present := s.physicalForConfig(config) + overview := s.configuredDeviceOverview(config, currentEntry, present) + if err := writeSSEEvent(w, controller, "overview", overview); err != nil { + return true + } + } + } +} + +// operatorCandidateWire maps a scanned network to the candidate shape the SPA +// reads. The SSE stream is not run through the api client's camelizer, so keys +// are emitted already-camelCase (the blocking endpoint's camelize leaves them +// unchanged). includesPcsDigit is a North-American PCS PLMN concept the modem +// layer does not derive, so it is always false here. +func operatorCandidateWire(op device.ScannedOperator) map[string]any { + rats := []string{} + if op.Act != "" { + rats = []string{op.Act} + } + return map[string]any{ + "status": op.Status, + "operatorName": op.Name, + "shortName": op.Short, + "plmn": op.Numeric, + "rats": rats, + "includesPcsDigit": false, + } +} + +func operatorCandidatesWire(operators []device.ScannedOperator) []map[string]any { + candidates := make([]map[string]any, 0, len(operators)) + for _, op := range operators { + candidates = append(candidates, operatorCandidateWire(op)) + } + return candidates +} + +// handleOperatorScan runs a blocking, abortable operator scan and returns the +// discovered networks in one response. +func (s *Server) handleOperatorScan(w http.ResponseWriter, r *http.Request, physicalID string) bool { + if !requireMethod(w, r, http.MethodGet) { + return true + } + result, err := s.devices.ScanOperators(r.Context(), physicalID) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "scanId": fmt.Sprintf("scan-%d", time.Now().UnixNano()), + "status": result.Status, + "candidates": operatorCandidatesWire(result.Operators), + }, + }) + return true +} + +// handleOperatorScanStream reports scan progress over SSE: an initial "running" +// event followed by a terminal "complete" (with candidates) or "failed" event. +func (s *Server) handleOperatorScanStream(w http.ResponseWriter, r *http.Request, physicalID string) bool { + if !requireMethod(w, r, http.MethodGet) { + return true + } + controller := beginSSE(w) + scanID := fmt.Sprintf("scan-%d", time.Now().UnixNano()) + if err := writeSSEEvent(w, controller, "operator_scan", map[string]any{ + "scanId": scanID, + "status": "running", + }); err != nil { + return true + } + result, err := s.devices.ScanOperators(r.Context(), physicalID) + if err != nil { + _ = writeSSEEvent(w, controller, "operator_scan", map[string]any{ + "scanId": scanID, + "status": "failed", + "message": err.Error(), + "retryable": true, + }) + return true + } + _ = writeSSEEvent(w, controller, "operator_scan", map[string]any{ + "scanId": scanID, + "status": result.Status, + "candidates": operatorCandidatesWire(result.Operators), + }) + return true +} + +// handleUSSDContinue continues an open USSD dialog. The session id (returned by +// the initial ussd request) selects the device. +func (s *Server) handleUSSDContinue(w http.ResponseWriter, r *http.Request) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + var request struct { + SessionID string `json:"session_id"` + Session string `json:"sessionId"` + Input string `json:"input"` + Command string `json:"command"` + TimeoutMs int `json:"timeout_ms"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + sessionID := firstNonEmpty(request.SessionID, request.Session) + if sessionID == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required") + return true + } + input := firstNonEmpty(request.Input, request.Command) + ctx, cancel := actionRequestContext(r.Context(), request.TimeoutMs) + defer cancel() + result, err := s.devices.ContinueUSSD(ctx, sessionID, input) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeUSSDResult(w, result) + return true +} + +// handleUSSDCancel aborts an open USSD dialog. +func (s *Server) handleUSSDCancel(w http.ResponseWriter, r *http.Request) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + var request struct { + SessionID string `json:"session_id"` + Session string `json:"sessionId"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + sessionID := firstNonEmpty(request.SessionID, request.Session) + if sessionID == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "session_id is required") + return true + } + if err := s.devices.CancelUSSD(r.Context(), sessionID); err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"cancelled": true, "session_id": sessionID}, + }) + return true +} + +func writeUSSDResult(w http.ResponseWriter, result device.USSDResult) { + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "result": map[string]any{ + "status": result.Status, + "text": result.Text, + "raw": result.Raw, + "dcs": result.DCS, + "continueable": result.Continueable, + }, + "session_id": result.SessionID, + }, + }) +} + +// handleFixUSBNet sets the USB network mode on a discovered-but-unmanaged modem, +// addressed by its AT port. Used to rescue a modem stuck in the wrong USB mode +// before it is taken over. +func (s *Server) handleFixUSBNet(w http.ResponseWriter, r *http.Request) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + var request struct { + ATPort string `json:"at_port"` + Mode *int `json:"mode"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return true + } + mode := 0 + if request.Mode != nil { + mode = *request.Mode + } + result, err := s.devices.SetUSBNetModeByPort(r.Context(), request.ATPort, mode) + if err != nil { + s.writeDeviceError(w, err) + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + return true +} diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go new file mode 100644 index 0000000..207f28f --- /dev/null +++ b/internal/server/device_features_api_test.go @@ -0,0 +1,343 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "vocat/internal/device" + "vocat/internal/store" +) + +func decodeData(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any { + t.Helper() + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v (body=%s)", err, recorder.Body.String()) + } + return envelope.Data +} + +func TestAttachSingleEUICCIdentityFillsProfileGroupMetadataKey(t *testing.T) { + groups := []map[string]any{{"eid": "", "aidHex": "", "profiles": []any{}}} + chipInfo := map[string]any{ + "eids": []any{map[string]any{ + "eid": "89086030202200000025000015085962", + "aid": "A0000005591010FFFFFFFF8900000100", + }}, + } + attachSingleEUICCIdentity(groups, chipInfo) + if groups[0]["eid"] != "89086030202200000025000015085962" { + t.Fatalf("group EID = %v", groups[0]["eid"]) + } + if groups[0]["aidHex"] != "A0000005591010FFFFFFFF8900000100" { + t.Fatalf("group AID = %v", groups[0]["aidHex"]) + } +} + +func TestHandleOperatorScanReturnsOperators(t *testing.T) { + server := &Server{ + logger: regionTestLogger(), + devices: fakeDeviceController{scanResult: device.OperatorScanResult{ + Status: "complete", + Operators: []device.ScannedOperator{ + {Status: "current", Name: "China Mobile", Numeric: "46000", Act: "LTE"}, + {Status: "available", Name: "China Unicom", Numeric: "46001", Act: "LTE"}, + }, + }}, + } + recorder := httptest.NewRecorder() + server.handleOperatorScan(recorder, httptest.NewRequest(http.MethodGet, "/scan", nil), "dev1") + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + data := decodeData(t, recorder) + if data["status"] != "complete" { + t.Fatalf("scan status = %v", data["status"]) + } + candidates, ok := data["candidates"].([]any) + if !ok || len(candidates) != 2 { + t.Fatalf("candidates = %v", data["candidates"]) + } + first := candidates[0].(map[string]any) + if first["plmn"] != "46000" || first["status"] != "current" || first["operatorName"] != "China Mobile" { + t.Fatalf("first candidate = %v", first) + } +} + +func TestHandleOperatorScanStreamEmitsTerminalEvent(t *testing.T) { + server := &Server{ + logger: regionTestLogger(), + devices: fakeDeviceController{scanResult: device.OperatorScanResult{ + Status: "complete", + Operators: []device.ScannedOperator{{Status: "current", Name: "CMCC", Numeric: "46000"}}, + }}, + } + recorder := httptest.NewRecorder() + server.handleOperatorScanStream(recorder, httptest.NewRequest(http.MethodGet, "/scan/stream", nil), "dev1") + body := recorder.Body.String() + if !strings.Contains(body, "event: operator_scan") { + t.Fatalf("expected operator_scan events, got %q", body) + } + if !strings.Contains(body, `"status":"running"`) || !strings.Contains(body, `"status":"complete"`) { + t.Fatalf("expected running then complete, got %q", body) + } +} + +func TestHandleUSSDContinueAndCancel(t *testing.T) { + server := &Server{ + logger: regionTestLogger(), + maxRequestBodyBytes: 4096, + devices: fakeDeviceController{ussdResult: device.USSDResult{ + Status: "awaiting_input", Text: "Main menu", SessionID: "abc123", Continueable: true, + }}, + } + request := httptest.NewRequest(http.MethodPost, "/continue", strings.NewReader(`{"session_id":"abc123","input":"1"}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.handleUSSDContinue(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("continue status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + data := decodeData(t, recorder) + result, _ := data["result"].(map[string]any) + if result["status"] != "awaiting_input" || data["session_id"] != "abc123" { + t.Fatalf("continue data = %v", data) + } + + cancelReq := httptest.NewRequest(http.MethodPost, "/cancel", strings.NewReader(`{"session_id":"abc123"}`)) + cancelReq.Header.Set("Content-Type", "application/json") + cancelRec := httptest.NewRecorder() + server.handleUSSDCancel(cancelRec, cancelReq) + if cancelRec.Code != http.StatusOK { + t.Fatalf("cancel status = %d, body=%s", cancelRec.Code, cancelRec.Body.String()) + } +} + +func TestHandleUSSDContinueRequiresSession(t *testing.T) { + server := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}} + request := httptest.NewRequest(http.MethodPost, "/continue", strings.NewReader(`{"input":"1"}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.handleUSSDContinue(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("missing session status = %d, want 400", recorder.Code) + } +} + +func TestHandleCardPoliciesListsAll(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertCardPolicy(context.Background(), store.CardPolicy{ + ICCID: "89860001", NetworkEnabled: true, IPVersion: "IPV4V6", Source: "manual", + }); err != nil { + t.Fatal(err) + } + server := &Server{store: database, logger: regionTestLogger()} + recorder := httptest.NewRecorder() + server.handleCardPolicies(recorder, httptest.NewRequest(http.MethodGet, "/api/cards/policies", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d", recorder.Code) + } + var envelope struct { + Data []map[string]any `json:"data"` + } + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatal(err) + } + if len(envelope.Data) != 1 || envelope.Data[0]["iccid"] != "89860001" { + t.Fatalf("policies = %v", envelope.Data) + } +} + +func TestHandleESIMShapes(t *testing.T) { + server := &Server{logger: regionTestLogger()} + + overview := httptest.NewRecorder() + server.handleESIM(overview, httptest.NewRequest(http.MethodGet, "/esim", nil), []string{}, "dev1", false) + if overview.Code != http.StatusOK { + t.Fatalf("overview status = %d", overview.Code) + } + if data := decodeData(t, overview); data["chipInfo"] != nil { + t.Fatalf("overview chipInfo = %v, want nil (empty state)", data["chipInfo"]) + } + + profiles := httptest.NewRecorder() + server.handleESIM(profiles, httptest.NewRequest(http.MethodGet, "/esim/profiles", nil), []string{"profiles"}, "dev1", false) + if profiles.Code != http.StatusOK { + t.Fatalf("profiles status = %d", profiles.Code) + } + + notif := httptest.NewRecorder() + server.handleESIM(notif, httptest.NewRequest(http.MethodGet, "/esim/notifications", nil), []string{"notifications"}, "dev1", false) + if notif.Code != http.StatusOK { + t.Fatalf("notifications status = %d", notif.Code) + } + + // Download is a GET+SSE endpoint, so POST is rejected. + downloadPost := httptest.NewRecorder() + server.handleESIM(downloadPost, httptest.NewRequest(http.MethodPost, "/esim/actions/download", nil), []string{"actions", "download"}, "dev1", false) + if downloadPost.Code != http.StatusMethodNotAllowed { + t.Fatalf("download POST status = %d, want 405", downloadPost.Code) + } + + // Download with no device manager reports 503. + download := httptest.NewRecorder() + server.handleESIM(download, httptest.NewRequest(http.MethodGet, "/esim/actions/download?smdp=rsp.example.com", nil), []string{"actions", "download"}, "dev1", false) + if download.Code != http.StatusServiceUnavailable { + t.Fatalf("download (no device) status = %d, want 503", download.Code) + } + + // Switch with no physical modem present reports 503. + absent := httptest.NewRecorder() + server.handleESIM(absent, httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001"}`)), []string{"actions", "switch"}, "dev1", false) + if absent.Code != http.StatusServiceUnavailable { + t.Fatalf("switch (no device) status = %d, want 503", absent.Code) + } + + // Switch happy path: a present device + fake controller switches by ICCID. + present := &Server{logger: regionTestLogger(), maxRequestBodyBytes: 4096, devices: fakeDeviceController{}} + swOK := httptest.NewRecorder() + swReq := httptest.NewRequest(http.MethodPost, "/esim/actions/switch", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0"}`)) + swReq.Header.Set("Content-Type", "application/json") + present.handleESIM(swOK, swReq, []string{"actions", "switch"}, "dev1", true) + if swOK.Code != http.StatusOK { + t.Fatalf("switch happy-path status = %d, body=%s", swOK.Code, swOK.Body.String()) + } + if data := decodeData(t, swOK); data["status"] != "switched" || data["verified"] != true { + t.Fatalf("switch data = %v", data) + } + + // Disable happy path routes the active profile to ES10c DisableProfile. + disableOK := httptest.NewRecorder() + disableReq := httptest.NewRequest(http.MethodPost, "/esim/actions/disable", strings.NewReader(`{"iccid":"8900000000000000001","aid_hex":"A0000005591010FFFFFFFF8900000100"}`)) + disableReq.Header.Set("Content-Type", "application/json") + present.handleESIM(disableOK, disableReq, []string{"actions", "disable"}, "dev1", true) + if disableOK.Code != http.StatusOK { + t.Fatalf("disable happy-path status = %d, body=%s", disableOK.Code, disableOK.Body.String()) + } + if data := decodeData(t, disableOK); data["status"] != "disabled" || data["recovering"] != true { + t.Fatalf("disable data = %v", data) + } + + // Rename happy path routes PATCH to ES10c SetNickname support. + renameOK := httptest.NewRecorder() + renameReq := httptest.NewRequest(http.MethodPatch, "/esim/profiles/8900000000000000001", strings.NewReader(`{"name":"Test profile","aid_hex":"A0000005591010FFFFFFFF8900000100"}`)) + renameReq.Header.Set("Content-Type", "application/json") + present.handleESIM(renameOK, renameReq, []string{"profiles", "8900000000000000001"}, "dev1", true) + if renameOK.Code != http.StatusOK { + t.Fatalf("rename happy-path status = %d, body=%s", renameOK.Code, renameOK.Body.String()) + } + if data := decodeData(t, renameOK); data["status"] != "renamed" || data["name"] != "Test profile" { + t.Fatalf("rename data = %v", data) + } + + // Download on a present device but with no smdp address reports 400. + dlNoSmdp := httptest.NewRecorder() + present.handleESIM(dlNoSmdp, httptest.NewRequest(http.MethodGet, "/esim/actions/download", nil), []string{"actions", "download"}, "dev1", true) + if dlNoSmdp.Code != http.StatusBadRequest { + t.Fatalf("download (no smdp) status = %d, want 400", dlNoSmdp.Code) + } +} + +func TestHandleFixUSBNet(t *testing.T) { + server := &Server{ + logger: regionTestLogger(), + maxRequestBodyBytes: 4096, + devices: fakeDeviceController{usbNetMode: device.USBNetMode{Mode: 0, Name: "QMI"}}, + } + request := httptest.NewRequest(http.MethodPost, "/fix-usbnet", strings.NewReader(`{"at_port":"/dev/ttyUSB2","mode":0}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.handleFixUSBNet(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + if data := decodeData(t, recorder); data["mode"] != float64(0) || data["name"] != "QMI" { + t.Fatalf("fix-usbnet data = %v", data) + } +} + +func TestHandleUpdateApplyIsSafeNoop(t *testing.T) { + server := &Server{logger: regionTestLogger()} + recorder := httptest.NewRecorder() + server.handleUpdateApply(recorder, httptest.NewRequest(http.MethodPost, "/apply", nil)) + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d", recorder.Code) + } + if data := decodeData(t, recorder); data["applied"] != false { + t.Fatalf("update apply must be a no-op, got %v", data) + } +} + +func TestE911WebsheetFlow(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + server := &Server{ + store: database, + logger: regionTestLogger(), + websheets: newWebsheetManager(), + maxRequestBodyBytes: 4096, + } + + // 1. Create the websheet. + createRec := httptest.NewRecorder() + server.handleE911Websheet(createRec, httptest.NewRequest(http.MethodPost, "/e911", nil), store.Device{ID: "dev1"}) + if createRec.Code != http.StatusOK { + t.Fatalf("create status = %d, body=%s", createRec.Code, createRec.Body.String()) + } + createData := decodeData(t, createRec) + embedURL, _ := createData["embed_url"].(string) + if embedURL == "" || !strings.HasPrefix(embedURL, "/websheets/") { + t.Fatalf("embed_url = %v", createData["embed_url"]) + } + + // 2. The form is served for a valid token. + formRec := httptest.NewRecorder() + server.handleWebsheet(formRec, httptest.NewRequest(http.MethodGet, embedURL, nil)) + if formRec.Code != http.StatusOK || !strings.Contains(formRec.Body.String(), "E911") { + t.Fatalf("form status = %d", formRec.Code) + } + + // 3. The callback stores the address, and done completes the session. + callbackURL := strings.Replace(embedURL, "?", "/callback?", 1) + callbackReq := httptest.NewRequest(http.MethodPost, callbackURL, strings.NewReader(`{"street":"1 Main St","city":"Springfield","country":"US"}`)) + callbackReq.Header.Set("Content-Type", "application/json") + callbackRec := httptest.NewRecorder() + server.handleWebsheet(callbackRec, callbackReq) + if callbackRec.Code != http.StatusOK { + t.Fatalf("callback status = %d, body=%s", callbackRec.Code, callbackRec.Body.String()) + } + stored, err := database.AppSetting(context.Background(), "e911_address:dev1") + if err != nil || !strings.Contains(string(stored.Value), "Springfield") { + t.Fatalf("e911 address not persisted: %v %v", stored, err) + } + + doneURL := strings.Replace(embedURL, "?", "/done?", 1) + doneRec := httptest.NewRecorder() + server.handleWebsheet(doneRec, httptest.NewRequest(http.MethodPost, doneURL, nil)) + if doneRec.Code != http.StatusOK { + t.Fatalf("done status = %d", doneRec.Code) + } +} + +func TestE911WebsheetRejectsBadToken(t *testing.T) { + server := &Server{logger: regionTestLogger(), websheets: newWebsheetManager()} + session := server.websheets.create("dev1") + recorder := httptest.NewRecorder() + server.handleWebsheet(recorder, httptest.NewRequest(http.MethodGet, "/websheets/"+session.id+"?token=wrong", nil)) + if recorder.Code != http.StatusForbidden { + t.Fatalf("bad token status = %d, want 403", recorder.Code) + } +} diff --git a/internal/server/device_vowifi_test.go b/internal/server/device_vowifi_test.go new file mode 100644 index 0000000..089828b --- /dev/null +++ b/internal/server/device_vowifi_test.go @@ -0,0 +1,201 @@ +package server + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "vocat/internal/store" + "vocat/internal/vowifi" + vowifiruntime "vocat/internal/vowifi/runtime" +) + +type fakeVoWiFiController struct { + state vowifi.State + enabled []bool + reconnects int + err error +} + +func (controller *fakeVoWiFiController) State(string) (vowifi.State, error) { + return controller.state, controller.err +} + +func (controller *fakeVoWiFiController) RequestEnabled( + _ string, + enabled bool, +) (vowifi.State, error) { + controller.enabled = append(controller.enabled, enabled) + return controller.state, controller.err +} + +func (controller *fakeVoWiFiController) RequestReconnect(string) (vowifi.State, error) { + controller.reconnects++ + return controller.state, controller.err +} + +func TestShouldDeferModemSMSSync(t *testing.T) { + tests := []struct { + name string + state vowifi.State + err error + want bool + }{ + { + name: "cellular session", + state: vowifi.State{Phase: vowifi.PhaseIdle}, + }, + { + name: "vowifi SIM setup", + state: vowifi.State{Enabled: true, Phase: vowifi.PhaseSIMReady}, + want: true, + }, + { + name: "vowifi IMS registration", + state: vowifi.State{Enabled: true, Phase: vowifi.PhaseIMSReady}, + want: true, + }, + { + name: "stable vowifi catch-up", + state: vowifi.State{Enabled: true, Phase: vowifi.PhaseSMSReady, SMSReady: true}, + }, + { + name: "failed vowifi cellular fallback", + state: vowifi.State{Enabled: true, Phase: vowifi.PhaseFailed}, + }, + { + name: "unknown vowifi state", + state: vowifi.State{Enabled: true, Phase: vowifi.PhaseSIMReady}, + err: context.Canceled, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := shouldDeferModemSMSSync(test.state, test.err); got != test.want { + t.Fatalf("shouldDeferModemSMSSync() = %v, want %v", got, test.want) + } + }) + } +} + +func TestVoWiFiEnableUpdatesPolicyAndQueuesRuntime(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + config := store.Device{ID: "ec20", Name: "EC20"} + if err := database.UpsertDevice(context.Background(), config); err != nil { + t.Fatal(err) + } + controller := &fakeVoWiFiController{ + state: vowifi.State{DeviceID: "ec20", Phase: vowifi.PhaseIdle}, + } + server := &Server{ + store: database, + vowifi: controller, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + maxRequestBodyBytes: 4096, + } + request := httptest.NewRequest( + http.MethodPatch, + "/api/devices/ec20/vowifi", + bytes.NewBufferString(`{"enabled":true}`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.handleVoWiFiEnabled(response, request, config, true) + + if response.Code != http.StatusAccepted { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if len(controller.enabled) != 1 || !controller.enabled[0] { + t.Fatalf("queued enables = %#v", controller.enabled) + } + stored, err := database.Device(context.Background(), "ec20") + if err != nil { + t.Fatal(err) + } + if !stored.VoWiFiEnabled { + t.Fatal("VoWiFi policy was not persisted") + } +} + +func TestVoWiFiRepeatedEnableWhileStartingIsAccepted(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + config := store.Device{ID: "ec20", Name: "EC20", VoWiFiEnabled: true} + if err := database.UpsertDevice(context.Background(), config); err != nil { + t.Fatal(err) + } + controller := &fakeVoWiFiController{ + // A reconnect briefly enters Stopping/Enabled=false while the persisted + // policy remains enabled. Repeating "enable" is still the same intent. + state: vowifi.State{DeviceID: "ec20", Phase: vowifi.PhaseStopping, Enabled: false}, + err: vowifiruntime.ErrOperationInProgress, + } + server := &Server{ + store: database, + vowifi: controller, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + maxRequestBodyBytes: 4096, + } + request := httptest.NewRequest( + http.MethodPatch, + "/api/devices/ec20/vowifi", + bytes.NewBufferString(`{"enabled":true}`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.handleVoWiFiEnabled(response, request, config, true) + + if response.Code != http.StatusAccepted { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + stored, err := database.Device(context.Background(), "ec20") + if err != nil { + t.Fatal(err) + } + if !stored.VoWiFiEnabled { + t.Fatal("idempotent enable reverted the desired policy") + } +} + +func TestVoWiFiReconnectRequiresEnabledPolicy(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + controller := &fakeVoWiFiController{} + server := &Server{ + store: database, + vowifi: controller, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + request := httptest.NewRequest( + http.MethodPost, + "/api/devices/ec20/vowifi/actions/reconnect", + nil, + ) + response := httptest.NewRecorder() + server.handleVoWiFiReconnect( + response, + request, + store.Device{ID: "ec20", Name: "EC20"}, + true, + ) + if response.Code != http.StatusConflict { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + if controller.reconnects != 0 { + t.Fatalf("reconnects = %d", controller.reconnects) + } +} diff --git a/internal/server/e911_api.go b/internal/server/e911_api.go new file mode 100644 index 0000000..eda387b --- /dev/null +++ b/internal/server/e911_api.go @@ -0,0 +1,253 @@ +package server + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" + + "vocat/internal/store" +) + +// websheetSession is a short-lived, token-authenticated E911 address +// provisioning session. The operator/carrier-hosted websheet VoHive embeds +// cannot be reproduced, so the backend self-hosts a minimal address form and +// relays the result into the device's VoWiFi record. +type websheetSession struct { + id string + token string + deviceID string + createdAt time.Time + expiresAt time.Time + address map[string]string + done bool +} + +type websheetManager struct { + mu sync.Mutex + sessions map[string]*websheetSession +} + +func newWebsheetManager() *websheetManager { + return &websheetManager{sessions: make(map[string]*websheetSession)} +} + +func websheetToken() string { + var b [16]byte + _, _ = rand.Read(b[:]) + return hex.EncodeToString(b[:]) +} + +func (m *websheetManager) create(deviceID string) *websheetSession { + now := time.Now().UTC() + session := &websheetSession{ + id: websheetToken()[:16], + token: websheetToken(), + deviceID: deviceID, + createdAt: now, + expiresAt: now.Add(15 * time.Minute), + } + m.mu.Lock() + m.sessions[session.id] = session + // Opportunistically drop expired sessions. + for id, value := range m.sessions { + if now.After(value.expiresAt) { + delete(m.sessions, id) + } + } + m.mu.Unlock() + return session +} + +func (m *websheetManager) get(id string) *websheetSession { + m.mu.Lock() + defer m.mu.Unlock() + session := m.sessions[id] + if session == nil || time.Now().UTC().After(session.expiresAt) { + return nil + } + return session +} + +// handleE911Websheet creates a self-hosted E911 websheet session for a device +// and returns the embeddable form URL (VoHive: POST /devices/{id}/vowifi/e911/websheet). +func (s *Server) handleE911Websheet( + w http.ResponseWriter, + r *http.Request, + config store.Device, +) bool { + if !requireMethod(w, r, http.MethodPost) { + return true + } + session := s.websheets.create(config.ID) + embedURL := fmt.Sprintf("/websheets/%s?token=%s", session.id, session.token) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "id": session.id, + "token": session.token, + "embed_url": embedURL, + "expires_at": session.expiresAt, + }, + }) + return true +} + +// handleWebsheet serves and drives the self-hosted E911 address form. These +// paths are token-authenticated (the token in the URL is the credential), so +// they live outside the session-gated /api tree. +// +// GET /websheets/{id}?token=... -> the address form +// POST /websheets/{id}/callback -> relay the entered address +// POST /websheets/{id}/done -> complete the session +func (s *Server) handleWebsheet(w http.ResponseWriter, r *http.Request) { + rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/websheets/"), "/") + segments := splitAPIPath(rest) + if len(segments) == 0 || segments[0] == "" { + writeError(w, http.StatusNotFound, "not_found", "websheet was not found") + return + } + session := s.websheets.get(segments[0]) + if session == nil { + writeError(w, http.StatusNotFound, "websheet_not_found", "websheet session was not found or has expired") + return + } + action := "" + if len(segments) > 1 { + action = segments[1] + } + switch { + case action == "" && r.Method == http.MethodGet: + s.serveWebsheetForm(w, r, session) + case action == "callback" && r.Method == http.MethodPost: + s.handleWebsheetCallback(w, r, session) + case action == "done" && r.Method == http.MethodPost: + s.handleWebsheetDone(w, r, session) + default: + w.Header().Set("Allow", "GET, POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) websheetTokenOK(r *http.Request, session *websheetSession) bool { + token := r.URL.Query().Get("token") + if token == "" { + token = r.Header.Get("X-Websheet-Token") + } + return token != "" && token == session.token +} + +func (s *Server) serveWebsheetForm(w http.ResponseWriter, r *http.Request, session *websheetSession) { + if !s.websheetTokenOK(r, session) { + writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid") + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = fmt.Fprint(w, websheetFormHTML(session)) +} + +func (s *Server) handleWebsheetCallback(w http.ResponseWriter, r *http.Request, session *websheetSession) { + if !s.websheetTokenOK(r, session) { + writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid") + return + } + var address map[string]string + if err := s.decodeJSON(w, r, &address); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + clean := make(map[string]string, len(address)) + for key, value := range address { + trimmed := strings.TrimSpace(value) + if trimmed != "" && len(trimmed) <= 256 { + clean[key] = trimmed + } + } + s.websheets.mu.Lock() + session.address = clean + s.websheets.mu.Unlock() + s.persistE911Address(r, session) + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"received": true}}) +} + +func (s *Server) handleWebsheetDone(w http.ResponseWriter, r *http.Request, session *websheetSession) { + if !s.websheetTokenOK(r, session) { + writeError(w, http.StatusForbidden, "invalid_token", "websheet token is invalid") + return + } + s.websheets.mu.Lock() + session.done = true + s.websheets.mu.Unlock() + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"done": true}}) +} + +// persistE911Address stores the provisioned E911 address against the device so +// the VoWiFi IMS registration can reference it later. +func (s *Server) persistE911Address(r *http.Request, session *websheetSession) { + payload, err := json.Marshal(session.address) + if err != nil { + return + } + if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{ + Key: "e911_address:" + session.deviceID, + Value: payload, + }); err != nil { + s.logger.Warn("persist e911 address failed", "device_id", session.deviceID, "error", err) + } +} + +// websheetFormHTML renders the self-contained E911 address form embedded by the +// frontend. On submit it relays the address to the callback, notifies the +// parent frame (vohive-websheet-callback), and completes the session. +func websheetFormHTML(session *websheetSession) string { + callbackURL := fmt.Sprintf("/websheets/%s/callback?token=%s", session.id, session.token) + doneURL := fmt.Sprintf("/websheets/%s/done?token=%s", session.id, session.token) + callbackJSON, _ := json.Marshal(callbackURL) + doneJSON, _ := json.Marshal(doneURL) + return ` + +E911 Address + +
+

E911 紧急地址登记

+

为 VoWiFi 服务登记紧急呼叫地址(Emergency Address)。

+
+ + + +
+
+ + +
+
+` +} diff --git a/internal/server/errors.go b/internal/server/errors.go new file mode 100644 index 0000000..31ec96a --- /dev/null +++ b/internal/server/errors.go @@ -0,0 +1,30 @@ +package server + +import ( + "encoding/json" + "net/http" +) + +type apiError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +type errorEnvelope struct { + Error apiError `json:"error"` +} + +func writeError(w http.ResponseWriter, status int, code string, message string) { + writeJSON(w, status, errorEnvelope{ + Error: apiError{ + Code: code, + Message: message, + }, + }) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(value) +} diff --git a/internal/server/esim_api.go b/internal/server/esim_api.go new file mode 100644 index 0000000..51b4db8 --- /dev/null +++ b/internal/server/esim_api.go @@ -0,0 +1,467 @@ +package server + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "vocat/internal/device" +) + +func esimUnavailable(w http.ResponseWriter) { + writeError(w, http.StatusNotImplemented, "esim_operation_unavailable", "This specific eSIM operation is not implemented.") +} + +// handleESIM routes every /devices/{id}/esim* path. +func (s *Server) handleESIM(w http.ResponseWriter, r *http.Request, rest []string, physicalID string, physicalPresent bool) bool { + if len(rest) == 0 || (len(rest) == 1 && strings.TrimSpace(rest[0]) == "") { + if !requireMethod(w, r, http.MethodGet) { + return true + } + s.writeEsimOverview(w, r, physicalID, physicalPresent) + return true + } + + switch rest[0] { + case "profiles": + if len(rest) == 1 { + if !requireMethod(w, r, http.MethodGet) { + return true + } + s.writeEsimGroups(w, r, physicalID, physicalPresent) + return true + } + if len(rest) == 2 && r.Method == http.MethodDelete { + s.handleEsimDelete(w, r, physicalID, physicalPresent, rest[1]) + return true + } + if len(rest) == 2 && r.Method == http.MethodPatch { + s.handleEsimRename(w, r, physicalID, physicalPresent, rest[1]) + return true + } + esimUnavailable(w) + return true + case "notifications": + if len(rest) == 1 { + if !requireMethod(w, r, http.MethodGet) { + return true + } + // No LPA download backend, so there are never pending notifications. + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"items": []any{}}}) + return true + } + // notifications/{id}/actions/retry + esimUnavailable(w) + return true + case "actions": + if len(rest) == 2 && rest[1] == "switch" { + if !requireMethod(w, r, http.MethodPost) { + return true + } + s.handleEsimSwitch(w, r, physicalID, physicalPresent) + return true + } + if len(rest) == 2 && rest[1] == "disable" { + if !requireMethod(w, r, http.MethodPost) { + return true + } + s.handleEsimDisable(w, r, physicalID, physicalPresent) + return true + } + if len(rest) == 2 && rest[1] == "download" { + if !requireMethod(w, r, http.MethodGet) { + return true + } + s.handleEsimDownload(w, r, physicalID, physicalPresent) + return true + } + // Any other provisioning action is not implemented. + esimUnavailable(w) + return true + default: + return false + } +} + +// esimInfo loads the eUICC profile list. The string result is "ok" (use info), +// "empty" (no usable eUICC — render the empty state), or "error" (an error +// response has already been written). +func (s *Server) esimInfo(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) (string, []device.EsimInventoryEntry) { + if s.devices == nil || !physicalPresent { + return "empty", nil + } + info, err := s.devices.ESIMInventory(r.Context(), physicalID) + if err != nil { + if errors.Is(err, device.ErrNoEUICC) { + return "empty", nil + } + s.writeDeviceError(w, err) + return "error", nil + } + return "ok", info +} + +// writeEsimOverview returns { chipInfo, profiles } for the eSIM tab. +func (s *Server) writeEsimOverview(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { + status, info := s.esimInfo(w, r, physicalID, physicalPresent) + switch status { + case "error": + return + case "empty": + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"chipInfo": nil, "profiles": []any{}}}) + return + } + chipInfo := esimInventoryChipInfo(info) + groups := esimInventoryGroups(info) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "chipInfo": chipInfo, + "profiles": groups, + }, + }) +} + +func esimInventoryChipInfo(entries []device.EsimInventoryEntry) map[string]any { + eids := make([]any, 0, len(entries)) + firmware := "" + for _, entry := range entries { + chip := entry.Chip + eid := map[string]any{"eid": chip.EID, "aid": chip.AID} + if chip.HasFreeNvram { + eid["freeNvramBytes"] = chip.FreeNvramBytes + eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024) + } + if chip.Manufacturer != "" { + eid["manufacturer"] = chip.Manufacturer + } + if len(chip.Certificates) > 0 { + eid["certificates"] = chip.Certificates + } + if len(chip.TrustedCIs) > 0 { + eid["trustedCiKeyIds"] = chip.TrustedCIs + } + if chip.DefaultSmdpAddress != "" { + eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress + } + if chip.RootDsAddress != "" { + eid["rootDsAddress"] = chip.RootDsAddress + } + if chip.SAS != "" { + eid["sasAccreditationNumber"] = chip.SAS + } + eids = append(eids, eid) + if firmware == "" { + firmware = chip.FirmwareVer + } + } + result := map[string]any{"eids": eids} + if firmware != "" { + result["firmware"] = firmware + } + return result +} + +func esimInventoryGroups(entries []device.EsimInventoryEntry) []map[string]any { + groups := make([]map[string]any, 0, len(entries)) + for _, entry := range entries { + groups = append(groups, esimGroups(entry.Info)...) + } + return groups +} + +// esimChipInfo reads the eUICC chip header (EID, firmware, free NVRAM, +// manufacturer, CI certificates, SM-DP+/Root SM-DS addresses, SAS, info source) +// for the eSIM tab. On any read failure it returns a sparse object so the +// profile list still renders. +func (s *Server) esimChipInfo(r *http.Request, physicalID string) map[string]any { + chip, err := s.devices.ESIMChipInfo(r.Context(), physicalID) + if err != nil || chip == nil { + return map[string]any{} + } + eid := map[string]any{ + "eid": chip.EID, + "aid": chip.AID, + } + if chip.HasFreeNvram { + eid["freeNvramBytes"] = chip.FreeNvramBytes + eid["freeNvram"] = fmt.Sprintf("%.2f KB", float64(chip.FreeNvramBytes)/1024) + } + if chip.Manufacturer != "" { + eid["manufacturer"] = chip.Manufacturer + } + if len(chip.Certificates) > 0 { + eid["certificates"] = chip.Certificates + } + if len(chip.TrustedCIs) > 0 { + eid["trustedCiKeyIds"] = chip.TrustedCIs + } + if chip.DefaultSmdpAddress != "" { + eid["defaultSmdpAddress"] = chip.DefaultSmdpAddress + } + if chip.RootDsAddress != "" { + eid["rootDsAddress"] = chip.RootDsAddress + } + if chip.SAS != "" { + eid["sasAccreditationNumber"] = chip.SAS + } + chipMap := map[string]any{ + "eids": []any{eid}, + } + if chip.FirmwareVer != "" { + chipMap["firmware"] = chip.FirmwareVer + } + return chipMap +} + +// writeEsimGroups returns just the profile groups for the /esim/profiles call. +func (s *Server) writeEsimGroups(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { + status, info := s.esimInfo(w, r, physicalID, physicalPresent) + switch status { + case "error": + return + case "empty": + writeJSON(w, http.StatusOK, map[string]any{"data": []any{}}) + return + } + groups := esimInventoryGroups(info) + writeJSON(w, http.StatusOK, map[string]any{"data": groups}) +} + +// GetProfilesInfo does not include an EID on every eUICC implementation. The +// EC20 hosts one physical eUICC, so associate the separately-read chip identity +// with that sole profile group. Without this, the SPA cannot match the group to +// its manufacturer/certificate/production metadata even though it was read. +func attachSingleEUICCIdentity(groups []map[string]any, chipInfo map[string]any) { + if len(groups) != 1 { + return + } + eids, ok := chipInfo["eids"].([]any) + if !ok || len(eids) != 1 { + return + } + identity, ok := eids[0].(map[string]any) + if !ok { + return + } + groupEID, _ := groups[0]["eid"].(string) + chipEID, _ := identity["eid"].(string) + if strings.TrimSpace(groupEID) == "" && strings.TrimSpace(chipEID) != "" { + groups[0]["eid"] = strings.TrimSpace(chipEID) + } + groupAID, _ := groups[0]["aidHex"].(string) + chipAID, _ := identity["aid"].(string) + if strings.TrimSpace(groupAID) == "" && strings.TrimSpace(chipAID) != "" { + groups[0]["aidHex"] = strings.TrimSpace(chipAID) + } +} + +// esimGroups flattens the eUICC profile list into the SPA's per-eUICC groups +// (the EC20 hosts a single eUICC, so this is normally one group). +func esimGroups(info device.EsimInfo) []map[string]any { + profiles := make([]map[string]any, 0, len(info.Profiles)) + for _, p := range info.Profiles { + profiles = append(profiles, map[string]any{ + "iccid": p.ICCID, + "name": firstNonEmpty(p.Nickname, p.Name), + "serviceProviderName": p.ServiceProvider, + "state": p.State, + "stateText": p.StateText, + "classText": p.Class, + }) + } + return []map[string]any{ + { + "eid": info.EID, + "aidHex": info.AID, + "profiles": profiles, + }, + } +} + +func (s *Server) handleEsimRename(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) { + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return + } + if !physicalPresent { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return + } + iccid = strings.TrimSpace(iccid) + if iccid == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") + return + } + var request struct { + Name string `json:"name"` + AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; ICCID addresses the profile + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + nickname := strings.TrimSpace(request.Name) + if nickname == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "profile nickname is required") + return + } + if err := s.devices.ESIMRenameProfile(r.Context(), physicalID, iccid, nickname, request.AIDHex); err != nil { + s.writeDeviceError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "renamed", "iccid": iccid, "name": nickname}}) +} + +// handleEsimSwitch enables one already-installed profile by ICCID (切卡). The +// eUICC EnableProfile command needs no authentication key. +func (s *Server) handleEsimSwitch(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return + } + if !physicalPresent { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return + } + var request struct { + ICCID string `json:"iccid"` + AIDHex string `json:"aid_hex"` // accepted for contract compatibility; switching keys off iccid + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + iccid := strings.TrimSpace(request.ICCID) + if iccid == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") + return + } + // A confirmed profile switch includes the EC20 reset and a live ICCID read, + // which normally takes longer than the server's ordinary response deadline. + controller := http.NewResponseController(w) + _ = controller.SetWriteDeadline(time.Time{}) + if err := s.devices.ESIMSwitchProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil { + s.writeDeviceError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "switched", "iccid": iccid, "verified": true}}) +} + +func (s *Server) handleEsimDisable(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return + } + if !physicalPresent { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return + } + var request struct { + ICCID string `json:"iccid"` + AIDHex string `json:"aid_hex"` // accepted for the multi-eUICC SPA contract; disabling keys off ICCID + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + iccid := strings.TrimSpace(request.ICCID) + if iccid == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") + return + } + if err := s.devices.ESIMDisableProfile(r.Context(), physicalID, iccid, request.AIDHex); err != nil { + s.writeDeviceError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"status": "disabled", "iccid": iccid, "recovering": true}}) +} + +func (s *Server) handleEsimDelete(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool, iccid string) { + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return + } + if !physicalPresent { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return + } + iccid = strings.TrimSpace(iccid) + if iccid == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "iccid is required") + return + } + result, err := s.devices.ESIMDeleteProfile(r.Context(), physicalID, iccid, r.URL.Query().Get("aid_hex")) + if err != nil { + s.writeDeviceError(w, err) + return + } + data := map[string]any{ + "status": "deleted", + "iccid": iccid, + "spaceDelta": map[string]any{"direction": "reclaimed", "bytes": result.SpaceDelta}, + } + if result.Warning != "" { + data["warning"] = result.Warning + } + writeJSON(w, http.StatusOK, map[string]any{"data": data}) +} + +// handleEsimDownload streams one eSIM profile download (写卡) as Server-Sent +// Events. The SPA drives it with GET + query params (smdp/matching_id/ +// confirmation_code/aid_hex/imei) and reads `data: {step,msg,pct,...}` lines. +// The event field names (step/msg/pct/code/space_delta/warning) match the +// reference contract byte-for-byte, so the frontend needs no changes. +func (s *Server) handleEsimDownload(w http.ResponseWriter, r *http.Request, physicalID string, physicalPresent bool) { + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return + } + if !physicalPresent { + writeError(w, http.StatusServiceUnavailable, "physical_device_missing", "the configured modem is not present on this Linux host") + return + } + query := r.URL.Query() + params := device.EsimDownloadParams{ + SMDP: query.Get("smdp"), + MatchingID: query.Get("matching_id"), + ConfirmationCode: query.Get("confirmation_code"), + AIDHex: query.Get("aid_hex"), + IMEI: query.Get("imei"), + } + if strings.TrimSpace(params.SMDP) == "" { + writeError(w, http.StatusBadRequest, "invalid_request", "smdp 为必填项") + return + } + + controller := beginSSE(w) + emit := func(payload map[string]any) { + // A failed write means the client went away; r.Context() is then already + // cancelled, so the device layer stops the download on its own. + _ = writeSSEEvent(w, controller, "progress", payload) + } + + result, err := s.devices.ESIMDownloadProfile(r.Context(), physicalID, params, func(p device.EsimProgress) { + emit(map[string]any{"step": p.Step, "msg": p.Msg, "pct": p.Pct}) + }) + if err != nil { + emit(map[string]any{ + "step": "error", + "msg": "下载失败: " + err.Error(), + "pct": -1, + "code": device.ESIMDownloadErrorCode(err), + }) + return + } + done := map[string]any{ + "step": "done", + "msg": "Profile 下载完成", + "pct": 100, + "space_delta": map[string]any{"direction": "consumed", "bytes": result.SpaceDelta}, + } + if result.Warning != "" { + done["warning"] = result.Warning + } + emit(done) +} diff --git a/internal/server/general_api.go b/internal/server/general_api.go new file mode 100644 index 0000000..64181c5 --- /dev/null +++ b/internal/server/general_api.go @@ -0,0 +1,410 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "runtime" + "strconv" + "strings" + "time" + + "vocat/internal/auth" + "vocat/internal/i18n" + "vocat/internal/loghub" + "vocat/internal/store" +) + +func (s *Server) routeGeneralAPI(w http.ResponseWriter, r *http.Request) bool { + cleanPath := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") + if s.routeSMSAPI(w, r, cleanPath) { + return true + } + if s.routeProxyAPI(w, r, cleanPath) { + return true + } + if s.routeSettingsAPI(w, r, cleanPath) { + return true + } + switch cleanPath { + case "logs/history": + s.handleLogHistory(w, r) + case "logs/stream": + s.handleLogStream(w, r) + case "system/info": + s.handleSystemInfo(w, r) + case "system/update/check": + s.handleUpdateCheck(w, r) + case "system/update/apply": + s.handleUpdateApply(w, r) + case "settings/password": + s.handlePasswordChange(w, r) + case "settings/preferences": + s.handleUIPreferences(w, r) + default: + return false + } + return true +} + +const uiPreferencesSettingKey = "ui.preferences" + +// loadUILanguage primes the process-level UI language (internal/i18n) from the +// persisted preference so backend-generated strings are translated correctly +// even before the first preferences request arrives after a restart. +func (s *Server) loadUILanguage(ctx context.Context) { + setting, err := s.store.AppSetting(ctx, uiPreferencesSettingKey) + if err != nil { + return + } + var document struct { + Language string `json:"language"` + } + if json.Unmarshal(setting.Value, &document) == nil { + i18n.Set(document.Language) + } +} + +// handleUIPreferences reads and writes UI preferences such as the interface +// language. Preferences live in the database so they stay consistent across +// the browsers and devices of the single administrator. +func (s *Server) handleUIPreferences(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.writeUIPreferences(w, r) + case http.MethodPut: + var request struct { + Language string `json:"language"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + language := strings.ToLower(strings.TrimSpace(request.Language)) + if language != "en" && language != "zh" { + writeError(w, http.StatusBadRequest, "invalid_language", "language must be \"en\" or \"zh\"") + return + } + raw, err := json.Marshal(map[string]string{"language": language}) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + return + } + if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{ + Key: uiPreferencesSettingKey, + Value: raw, + }); err != nil { + s.writeStoreError(w, err) + return + } + s.writeUIPreferences(w, r) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) writeUIPreferences(w http.ResponseWriter, r *http.Request) { + language := "en" + setting, err := s.store.AppSetting(r.Context(), uiPreferencesSettingKey) + switch { + case errors.Is(err, store.ErrNotFound): + case err != nil: + s.writeStoreError(w, err) + return + default: + var document struct { + Language string `json:"language"` + } + if json.Unmarshal(setting.Value, &document) == nil && + (document.Language == "en" || document.Language == "zh") { + language = document.Language + } + } + // Keep the process-level UI language in sync so backend-generated strings + // (status text, errors, hints) translate to match the SPA. + i18n.Set(language) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"language": language}, + }) +} + +func (s *Server) handleLogHistory(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + limit, err := strconv.Atoi(r.URL.Query().Get("lines")) + if err != nil || limit < 1 { + limit = 500 + } + if limit > 2000 { + limit = 2000 + } + minimum := logLevel(r.URL.Query().Get("level")) + search := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("search"))) + + // History is served from the persisted log_events table so it reflects the + // configured retention policy and survives restarts (the in-memory hub only + // backs the live stream). + entries := []loghub.Entry{} + if s.store != nil { + events, err := s.store.ListLogEvents(r.Context(), store.LogFilter{Limit: limit}) + if err != nil { + s.writeStoreError(w, err) + return + } + for _, event := range events { + if storedLogLevel(event.Level) < minimum { + continue + } + entry := storedLogToEntry(event) + if search != "" && !storedLogContains(entry, search) { + continue + } + entries = append(entries, entry) + } + // ListLogEvents is newest-first; present chronologically. + for i, j := 0, len(entries)-1; i < j; i, j = i+1, j-1 { + entries[i], entries[j] = entries[j], entries[i] + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"logs": entries}, + }) +} + +func storedLogLevel(value string) slog.Level { + switch strings.ToLower(strings.TrimSpace(value)) { + case "error": + return slog.LevelError + case "warn", "warning": + return slog.LevelWarn + case "debug": + return slog.LevelDebug + default: + return slog.LevelInfo + } +} + +func storedLogToEntry(event store.LogEvent) loghub.Entry { + var fields map[string]any + if len(event.Fields) > 0 { + if err := json.Unmarshal(event.Fields, &fields); err != nil { + fields = nil + } + } + return loghub.Entry{ + Time: event.Time, + Level: event.Level, + Message: event.Message, + Caller: event.Caller, + Fields: fields, + } +} + +func storedLogContains(entry loghub.Entry, search string) bool { + if strings.Contains(strings.ToLower(entry.Message), search) || + strings.Contains(strings.ToLower(entry.Caller), search) { + return true + } + for key, value := range entry.Fields { + if strings.Contains(strings.ToLower(key), search) || + strings.Contains(strings.ToLower(fmt.Sprint(value)), search) { + return true + } + } + return false +} + +func (s *Server) handleLogStream(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + if s.logs == nil { + writeError(w, http.StatusServiceUnavailable, "log_stream_unavailable", "live log stream is unavailable") + return + } + controller := http.NewResponseController(w) + if err := controller.SetWriteDeadline(time.Time{}); err != nil { + s.logger.Debug("stream write deadline is controlled by the HTTP server", "error", err) + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache, no-store") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("event: connected\ndata: {}\n\n")); err != nil { + return + } + if err := controller.Flush(); err != nil { + return + } + + minimum := logLevel(r.URL.Query().Get("level")) + entries, cancel := s.logs.Subscribe(128) + defer cancel() + heartbeat := time.NewTicker(20 * time.Second) + defer heartbeat.Stop() + encoder := json.NewEncoder(w) + for { + select { + case <-r.Context().Done(): + return + case <-heartbeat.C: + if _, err := w.Write([]byte(": keepalive\n\n")); err != nil { + return + } + if err := controller.Flush(); err != nil { + return + } + case entry, ok := <-entries: + if !ok { + return + } + if logLevel(entry.Level) < minimum { + continue + } + if _, err := w.Write([]byte("event: log\ndata: ")); err != nil { + return + } + if err := encoder.Encode(entry); err != nil { + return + } + if _, err := w.Write([]byte("\n")); err != nil { + return + } + if err := controller.Flush(); err != nil { + return + } + } + } +} + +func logLevel(value string) slog.Level { + switch strings.ToLower(strings.TrimSpace(value)) { + case "error": + return slog.LevelError + case "warn", "warning": + return slog.LevelWarn + case "debug": + return slog.LevelDebug + default: + return slog.LevelInfo + } +} + +func (s *Server) handleSystemInfo(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "version": "0.1.0-dev", + "build_time": "", + "config": "VOCAT_CONFIG and environment", + "os": runtime.GOOS, + "architecture": runtime.GOARCH, + "uptime": formatDuration(time.Since(s.startedAt)), + }, + }) +} + +func (s *Server) handleUpdateCheck(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "available": false, + "version": "0.1.0-dev", + "message": i18n.T("未配置受信任的软件更新源;不会从未知地址下载或执行文件。"), + }, + }) +} + +// handleUpdateApply deliberately performs no update. Without a configured, +// trusted update channel the product never downloads or executes code, so an +// apply request is acknowledged as a safe no-op rather than acted on. +func (s *Server) handleUpdateApply(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "applied": false, + "message": i18n.T("未配置受信任的软件更新源;未执行任何更新。"), + }, + }) +} + +func (s *Server) handlePasswordChange(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + var request struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + ConfirmPassword string `json:"confirm_password"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if request.NewPassword != request.ConfirmPassword { + writeError(w, http.StatusBadRequest, "password_mismatch", "new password and confirmation do not match") + return + } + sessionToken, ok := s.sessionToken(w, r) + if !ok { + return + } + session, err := s.auth.Authenticate(r.Context(), sessionToken) + if err != nil { + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required") + return + } + if err := s.auth.ChangePassword( + r.Context(), + session.Principal.Username, + request.OldPassword, + request.NewPassword, + ); err != nil { + switch { + case errors.Is(err, auth.ErrInvalidCredentials): + writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password is incorrect") + case strings.Contains(err.Error(), "between 12 and 1024"): + writeError(w, http.StatusBadRequest, "weak_password", err.Error()) + case strings.Contains(err.Error(), "must differ"): + writeError(w, http.StatusBadRequest, "password_reused", err.Error()) + default: + s.logger.Error("password change failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + } + return + } + s.clearAuthCookies(w) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"changed": true, "reauthentication_required": true}, + }) +} + +func formatDuration(duration time.Duration) string { + if duration < 0 { + duration = 0 + } + days := int(duration / (24 * time.Hour)) + duration %= 24 * time.Hour + hours := int(duration / time.Hour) + duration %= time.Hour + minutes := int(duration / time.Minute) + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} diff --git a/internal/server/logging_api.go b/internal/server/logging_api.go new file mode 100644 index 0000000..b340bf8 --- /dev/null +++ b/internal/server/logging_api.go @@ -0,0 +1,161 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "vocat/internal/store" +) + +const loggingSettingKey = "logs.retention" + +// loggingConfig is the persisted log retention policy. +type loggingConfig struct { + Mode string `json:"mode"` // "unlimited" (default) | "count" | "days" + Count int `json:"count"` // keep newest N entries when mode is "count" + Days int `json:"days"` // keep entries from the last N days when mode is "days" +} + +func defaultLoggingConfig() loggingConfig { + return loggingConfig{Mode: "unlimited", Count: 10000, Days: 30} +} + +func parseLoggingConfig(config loggingConfig) (loggingConfig, error) { + mode := strings.ToLower(strings.TrimSpace(config.Mode)) + if mode == "" { + mode = "unlimited" + } + if mode != "unlimited" && mode != "count" && mode != "days" { + return loggingConfig{}, errors.New("mode must be \"unlimited\", \"count\", or \"days\"") + } + config.Mode = mode + if config.Count < 1 { + config.Count = 10000 + } + if config.Days < 1 { + config.Days = 30 + } + return config, nil +} + +// loadLoggingConfig reads the persisted retention policy, defaulting to unlimited. +func (s *Server) loadLoggingConfig(ctx context.Context) loggingConfig { + config := defaultLoggingConfig() + setting, err := s.store.AppSetting(ctx, loggingSettingKey) + if err == nil { + var stored loggingConfig + if json.Unmarshal(setting.Value, &stored) == nil { + if parsed, parseErr := parseLoggingConfig(stored); parseErr == nil { + config = parsed + } + } + } + return config +} + +// applyLogRetention enforces the current retention policy against the persisted +// log events. The "unlimited" mode prunes nothing. +func (s *Server) applyLogRetention(ctx context.Context) error { + config := s.loadLoggingConfig(ctx) + switch config.Mode { + case "days": + cutoff := time.Now().UTC().Add(-time.Duration(config.Days) * 24 * time.Hour) + _, err := s.store.PruneLogEvents(ctx, cutoff) + return err + case "count": + _, err := s.store.PruneLogEventsToCount(ctx, config.Count) + return err + default: + return nil + } +} + +// StartLogRetentionLoop enforces the retention policy once at startup and then +// on the given interval until the context is cancelled. +func (s *Server) StartLogRetentionLoop(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = time.Minute + } + if err := s.applyLogRetention(ctx); err != nil { + s.logger.Warn("apply log retention failed", "error", err) + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + if err := s.applyLogRetention(ctx); err != nil { + s.logger.Warn("apply log retention failed", "error", err) + } + } + } +} + +// handleLoggingSettings reads and writes the log retention policy. +// +// GET /api/settings/logging +// PUT /api/settings/logging +func (s *Server) handleLoggingSettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + config := s.loadLoggingConfig(r.Context()) + stored, err := s.store.CountLogEvents(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "mode": config.Mode, + "count": config.Count, + "days": config.Days, + "stored_logs": stored, + }, + }) + case http.MethodPut: + var request loggingConfig + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + config, err := parseLoggingConfig(request) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_logging_policy", err.Error()) + return + } + payload, err := json.Marshal(config) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + return + } + if err := s.store.UpsertAppSetting(r.Context(), store.AppSetting{ + Key: loggingSettingKey, + Value: payload, + }); err != nil { + s.writeStoreError(w, err) + return + } + s.audit(r, "settings.logging.update", "settings", "logging", "success") + if err := s.applyLogRetention(r.Context()); err != nil { + s.logger.Warn("apply log retention failed", "error", err) + } + stored, _ := s.store.CountLogEvents(r.Context()) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "mode": config.Mode, + "count": config.Count, + "days": config.Days, + "stored_logs": stored, + }, + }) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} diff --git a/internal/server/login_rate_limit.go b/internal/server/login_rate_limit.go new file mode 100644 index 0000000..fcacd22 --- /dev/null +++ b/internal/server/login_rate_limit.go @@ -0,0 +1,90 @@ +package server + +import ( + "sync" + "time" +) + +// loginRateLimiter blunts online brute-force attacks by temporarily locking a +// key (client IP + username) after too many consecutive failed logins. +type loginRateLimiter struct { + mu sync.Mutex + attempts map[string]*loginAttempt + maxFailures int + window time.Duration + lockout time.Duration + now func() time.Time +} + +type loginAttempt struct { + failures int + firstFail time.Time + lockedUntil time.Time +} + +func newLoginRateLimiter() *loginRateLimiter { + return &loginRateLimiter{ + attempts: make(map[string]*loginAttempt), + maxFailures: 5, + window: 10 * time.Minute, + lockout: 10 * time.Minute, + now: time.Now, + } +} + +// checkLocked reports whether the key is currently locked and for how much longer. +func (l *loginRateLimiter) checkLocked(key string) (time.Duration, bool) { + l.mu.Lock() + defer l.mu.Unlock() + attempt, ok := l.attempts[key] + if !ok { + return 0, false + } + now := l.now() + if now.Before(attempt.lockedUntil) { + return attempt.lockedUntil.Sub(now), true + } + return 0, false +} + +// recordFailure registers a failed attempt and locks the key once the failure +// threshold is reached within the window. It returns the lockout duration when +// a lock is newly applied. +func (l *loginRateLimiter) recordFailure(key string) (time.Duration, bool) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.now() + attempt, ok := l.attempts[key] + if !ok || now.Sub(attempt.firstFail) > l.window { + attempt = &loginAttempt{firstFail: now} + l.attempts[key] = attempt + } + attempt.failures++ + l.pruneLocked(now) + if attempt.failures >= l.maxFailures { + attempt.lockedUntil = now.Add(l.lockout) + attempt.failures = 0 + attempt.firstFail = now + return l.lockout, true + } + return 0, false +} + +func (l *loginRateLimiter) recordSuccess(key string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.attempts, key) +} + +// pruneLocked drops entries that are neither locked nor accumulating, so the +// map stays bounded. Callers must hold the lock. +func (l *loginRateLimiter) pruneLocked(now time.Time) { + if len(l.attempts) < 1024 { + return + } + for key, attempt := range l.attempts { + if now.After(attempt.lockedUntil) && now.Sub(attempt.firstFail) > l.window { + delete(l.attempts, key) + } + } +} diff --git a/internal/server/proxy_api.go b/internal/server/proxy_api.go new file mode 100644 index 0000000..8d55b24 --- /dev/null +++ b/internal/server/proxy_api.go @@ -0,0 +1,563 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "vocat/internal/i18n" + localproxy "vocat/internal/proxy" + "vocat/internal/store" +) + +func (s *Server) routeProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool { + switch cleanPath { + case "upstream-proxies": + s.handleUpstreamProxies(w, r) + case "upstream-proxy-probe": + s.handleUpstreamProbeConfig(w, r) + case "upstream-proxy-countries": + if !requireMethod(w, r, http.MethodGet) { + return true + } + writeJSON(w, http.StatusOK, map[string]any{"data": proxyCountries}) + case "upstream-proxy-country-rules": + s.handleCountryRules(w, r) + case "upstream-proxy-device-bindings": + s.handleDeviceProxyBindings(w, r) + default: + segments := splitAPIPath(cleanPath) + switch { + case len(segments) == 2 && segments[0] == "upstream-proxies": + s.handleUpstreamProxy(w, r, segments[1]) + case len(segments) == 4 && + segments[0] == "upstream-proxies" && + segments[2] == "actions" && + segments[3] == "probe": + s.handleUpstreamProbe(w, r, segments[1]) + case len(segments) == 2 && segments[0] == "upstream-proxy-country-rules": + s.handleCountryRule(w, r, segments[1]) + case len(segments) == 2 && segments[0] == "upstream-proxy-device-bindings": + s.handleDeviceProxyBinding(w, r, segments[1]) + default: + return false + } + } + return true +} + +type upstreamProxyPayload struct { + ID string `json:"id"` + Name string `json:"name"` + Addr string `json:"addr"` + Username string `json:"username"` + Password string `json:"password"` + Enabled bool `json:"enabled"` +} + +func (s *Server) handleUpstreamProxies(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + values, err := s.store.ListUpstreamProxies(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + result := make([]map[string]any, 0, len(values)) + for _, value := range values { + result = append(result, upstreamProxyResponse(value.Redacted())) + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + case http.MethodPost: + var payload upstreamProxyPayload + if err := s.decodeJSON(w, r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if !validObjectID(payload.ID) { + writeError(w, http.StatusBadRequest, "invalid_proxy_id", "proxy ID must use 1-64 safe characters") + return + } + s.saveAndProbeUpstream(w, r, payload) + default: + w.Header().Set("Allow", "GET, POST") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleUpstreamProxy(w http.ResponseWriter, r *http.Request, id string) { + switch r.Method { + case http.MethodPut: + var payload upstreamProxyPayload + if err := s.decodeJSON(w, r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if payload.ID != "" && payload.ID != id { + writeError(w, http.StatusConflict, "immutable_proxy_id", "upstream proxy ID cannot be changed") + return + } + payload.ID = id + s.saveAndProbeUpstream(w, r, payload) + case http.MethodDelete: + bindings, listErr := s.store.ListDeviceProxyBindings(r.Context()) + if listErr != nil { + s.writeStoreError(w, listErr) + return + } + if err := s.store.DeleteUpstreamProxy(r.Context(), id); err != nil { + s.writeStoreError(w, err) + return + } + for _, binding := range bindings { + if binding.UpstreamProxyID == id { + s.requestProxyRouteReconnect(binding.DeviceID) + } + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}}) + default: + w.Header().Set("Allow", "PUT, DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) handleDeviceProxyBindings(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + values, err := s.store.ListDeviceProxyBindings(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + result := make([]map[string]any, 0, len(values)) + for _, value := range values { + result = append(result, deviceProxyBindingResponse(value)) + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (s *Server) handleDeviceProxyBinding(w http.ResponseWriter, r *http.Request, deviceID string) { + deviceID = strings.TrimSpace(deviceID) + if !validDeviceID(deviceID) { + writeError(w, http.StatusBadRequest, "invalid_device_id", "device ID must use 1-64 safe characters") + return + } + if _, err := s.store.Device(r.Context(), deviceID); err != nil { + s.writeStoreError(w, err) + return + } + switch r.Method { + case http.MethodPut: + var request struct { + UpstreamProxyID string `json:"upstream_proxy_id"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + request.UpstreamProxyID = strings.TrimSpace(request.UpstreamProxyID) + upstream, err := s.store.UpstreamProxy(r.Context(), request.UpstreamProxyID) + if err != nil { + s.writeStoreError(w, err) + return + } + if !upstream.Enabled { + writeError(w, http.StatusConflict, "upstream_proxy_disabled", "enable the upstream proxy before binding a device") + return + } + // Once bound, a device may not be silently rebinded to a different + // upstream proxy. Force the caller to DELETE first so the change is + // intentional. Re-binding the same upstream stays idempotent. + if existing, err := s.store.DeviceProxyBinding(r.Context(), deviceID); err == nil && existing.UpstreamProxyID != upstream.ID { + writeError(w, http.StatusConflict, "device_already_bound", "device is already bound to another upstream proxy; delete the binding first") + return + } else if err != nil && !errors.Is(err, store.ErrNotFound) { + s.writeStoreError(w, err) + return + } + value := store.DeviceProxyBinding{DeviceID: deviceID, UpstreamProxyID: upstream.ID} + if err := s.store.UpsertDeviceProxyBinding(r.Context(), value); err != nil { + s.writeStoreError(w, err) + return + } + reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID) + response := deviceProxyBindingResponse(value) + response["reconnect_requested"] = reconnected + if reconnectErr != nil { + response["reconnect_error"] = reconnectErr.Error() + } + writeJSON(w, http.StatusOK, map[string]any{"data": response}) + case http.MethodDelete: + if err := s.store.DeleteDeviceProxyBinding(r.Context(), deviceID); err != nil { + s.writeStoreError(w, err) + return + } + reconnected, reconnectErr := s.requestProxyRouteReconnect(deviceID) + response := map[string]any{"deleted": true, "reconnect_requested": reconnected} + if reconnectErr != nil { + response["reconnect_error"] = reconnectErr.Error() + } + writeJSON(w, http.StatusOK, map[string]any{"data": response}) + default: + w.Header().Set("Allow", "PUT, DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +// A binding is already durable before this is called. Reconnect failures are +// returned as advisory information: the chosen route will still be used on +// the next VoWiFi start/reconnect. +func (s *Server) requestProxyRouteReconnect(deviceID string) (bool, error) { + if s.vowifi == nil { + return false, nil + } + config, err := s.store.Device(context.Background(), deviceID) + if err != nil { + return false, err + } + if !config.VoWiFiEnabled { + return false, nil + } + if _, err := s.vowifi.RequestReconnect(deviceID); err != nil { + s.logger.Warn("VoWiFi proxy route saved but immediate reconnect was not started", "device_id", deviceID, "error", err) + return false, err + } + return true, nil +} + +func (s *Server) saveAndProbeUpstream( + w http.ResponseWriter, + r *http.Request, + payload upstreamProxyPayload, +) { + value := store.UpstreamProxy{ + ID: payload.ID, + Name: payload.Name, + Addr: payload.Addr, + Username: payload.Username, + Password: payload.Password, + Enabled: payload.Enabled, + } + if err := s.store.UpsertUpstreamProxy(r.Context(), value); err != nil { + s.writeStoreError(w, err) + return + } + saved, err := s.store.UpstreamProxy(r.Context(), value.ID) + if err != nil { + s.writeStoreError(w, err) + return + } + bindings, err := s.store.ListDeviceProxyBindings(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + for _, binding := range bindings { + if binding.UpstreamProxyID == saved.ID { + s.requestProxyRouteReconnect(binding.DeviceID) + } + } + probe, probeErr := localproxy.ProbeSOCKS5( + r.Context(), + saved.Addr, + saved.Username, + saved.Password, + 8*time.Second, + ) + probeResponse := probeMap(probe, probeErr) + message := i18n.T("代理已保存;UDP ASSOCIATE 尚未通过。") + if probeErr == nil && probe.UDPAssociateOK { + message = i18n.T("代理已保存,SOCKS5 认证与 UDP ASSOCIATE 均通过。") + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "status": "saved", + "proxy": upstreamProxyResponse(saved.Redacted()), + "probe": probeResponse, + "message": message, + }, + }) +} + +func (s *Server) handleUpstreamProbe(w http.ResponseWriter, r *http.Request, id string) { + if !requireMethod(w, r, http.MethodPost) { + return + } + value, err := s.store.UpstreamProxy(r.Context(), id) + if err != nil { + s.writeStoreError(w, err) + return + } + result, probeErr := localproxy.ProbeSOCKS5( + r.Context(), + value.Addr, + value.Username, + value.Password, + 8*time.Second, + ) + message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。") + if probeErr == nil && result.UDPAssociateOK { + message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。") + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "status": "probed", + "probe": probeMap(result, probeErr), + "message": message, + }, + }) +} + +// handleUpstreamProbeConfig probes a front proxy straight from the editor's +// form values, so connectivity (above all UDP ASSOCIATE, which VoWiFi depends +// on) can be verified before the proxy is ever saved. When the form edits an +// existing proxy and leaves the password blank (meaning "keep the stored +// secret"), the stored record supplies the missing credentials. +func (s *Server) handleUpstreamProbeConfig(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + var payload upstreamProxyPayload + if err := s.decodeJSON(w, r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + addr := strings.TrimSpace(payload.Addr) + username := strings.TrimSpace(payload.Username) + password := payload.Password + if id := strings.TrimSpace(payload.ID); id != "" { + if stored, err := s.store.UpstreamProxy(r.Context(), id); err == nil { + if addr == "" { + addr = stored.Addr + } + if username == "" { + username = stored.Username + } + if password == "" || password == store.SecretMask { + password = stored.Password + } + } + } + if addr == "" { + writeError(w, http.StatusBadRequest, "invalid_proxy_addr", "Socks5 address is required") + return + } + result, probeErr := localproxy.ProbeSOCKS5( + r.Context(), + addr, + username, + password, + 8*time.Second, + ) + message := i18n.T("代理不能承载 VoWiFi 所需的 UDP。") + if probeErr == nil && result.UDPAssociateOK { + message = i18n.T("SOCKS5 认证与 UDP ASSOCIATE 探测通过。") + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "status": "probed", + "probe": probeMap(result, probeErr), + "message": message, + }, + }) +} + +func (s *Server) handleCountryRules(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + values, err := s.store.ListCountryRules(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + result := make([]map[string]any, 0, len(values)) + for _, value := range values { + result = append(result, countryRuleResponse(value)) + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (s *Server) handleCountryRule(w http.ResponseWriter, r *http.Request, countryCode string) { + countryCode = strings.ToUpper(strings.TrimSpace(countryCode)) + switch r.Method { + case http.MethodPut: + var request struct { + UpstreamProxyID string `json:"upstream_proxy_id"` + Enabled bool `json:"enabled"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + country := countryByCode(countryCode) + if country == nil { + writeError(w, http.StatusBadRequest, "invalid_country", "country code is not in the supported MCC table") + return + } + if _, err := s.store.UpstreamProxy(r.Context(), request.UpstreamProxyID); err != nil { + s.writeStoreError(w, err) + return + } + value := store.CountryRule{ + CountryCode: countryCode, + CountryName: country.Name, + UpstreamProxyID: request.UpstreamProxyID, + Enabled: request.Enabled, + } + if err := s.store.UpsertCountryRule(r.Context(), value); err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": countryRuleResponse(value)}) + case http.MethodDelete: + if err := s.store.DeleteCountryRule(r.Context(), countryCode); err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}}) + default: + w.Header().Set("Allow", "PUT, DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func upstreamProxyResponse(value store.UpstreamProxy) map[string]any { + return map[string]any{ + "id": value.ID, + "name": value.Name, + "addr": value.Addr, + "username": value.Username, + "password": value.Password, + "enabled": value.Enabled, + } +} + +func countryRuleResponse(value store.CountryRule) map[string]any { + return map[string]any{ + "country_code": value.CountryCode, + "country_name": value.CountryName, + "upstream_proxy_id": value.UpstreamProxyID, + "enabled": value.Enabled, + } +} + +func deviceProxyBindingResponse(value store.DeviceProxyBinding) map[string]any { + return map[string]any{ + "device_id": value.DeviceID, + "upstream_proxy_id": value.UpstreamProxyID, + } +} + +func probeMap(result localproxy.ProbeResult, err error) map[string]any { + encoded, _ := json.Marshal(result) + var response map[string]any + _ = json.Unmarshal(encoded, &response) + if err != nil { + response["error"] = err.Error() + } + return response +} + +func validObjectID(value string) bool { + return validDeviceID(value) +} + +type proxyCountry struct { + Code string + Name string + MCCs []string +} + +func (country proxyCountry) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]any{ + "country_code": country.Code, + "country_name": i18n.T(country.Name), + "mccs": country.MCCs, + }) +} + +func countryByCode(code string) *proxyCountry { + for index := range proxyCountries { + if proxyCountries[index].Code == code { + return &proxyCountries[index] + } + } + return nil +} + +// countryNameForMCC resolves a mobile country code to a display name using the +// shared MCC table. It returns an empty string for an unknown or empty MCC. +func countryNameForMCC(mcc string) string { + if mcc == "" { + return "" + } + for index := range proxyCountries { + for _, candidate := range proxyCountries[index].MCCs { + if candidate == mcc { + return i18n.T(proxyCountries[index].Name) + } + } + } + return "" +} + +var proxyCountries = []proxyCountry{ + {Code: "CN", Name: "中国", MCCs: []string{"460", "461"}}, + {Code: "HK", Name: "中国香港", MCCs: []string{"454"}}, + {Code: "MO", Name: "中国澳门", MCCs: []string{"455"}}, + {Code: "TW", Name: "中国台湾", MCCs: []string{"466"}}, + {Code: "US", Name: "美国", MCCs: []string{"310", "311", "312", "313", "314", "315", "316"}}, + {Code: "CA", Name: "加拿大", MCCs: []string{"302"}}, + {Code: "GB", Name: "英国", MCCs: []string{"234", "235"}}, + {Code: "DE", Name: "德国", MCCs: []string{"262"}}, + {Code: "FR", Name: "法国", MCCs: []string{"208"}}, + {Code: "IT", Name: "意大利", MCCs: []string{"222"}}, + {Code: "ES", Name: "西班牙", MCCs: []string{"214"}}, + {Code: "PT", Name: "葡萄牙", MCCs: []string{"268"}}, + {Code: "NL", Name: "荷兰", MCCs: []string{"204"}}, + {Code: "BE", Name: "比利时", MCCs: []string{"206"}}, + {Code: "CH", Name: "瑞士", MCCs: []string{"228"}}, + {Code: "AT", Name: "奥地利", MCCs: []string{"232"}}, + {Code: "IE", Name: "爱尔兰", MCCs: []string{"272"}}, + {Code: "DK", Name: "丹麦", MCCs: []string{"238"}}, + {Code: "SE", Name: "瑞典", MCCs: []string{"240"}}, + {Code: "NO", Name: "挪威", MCCs: []string{"242"}}, + {Code: "FI", Name: "芬兰", MCCs: []string{"244"}}, + {Code: "PL", Name: "波兰", MCCs: []string{"260"}}, + {Code: "CZ", Name: "捷克", MCCs: []string{"230"}}, + {Code: "GR", Name: "希腊", MCCs: []string{"202"}}, + {Code: "RO", Name: "罗马尼亚", MCCs: []string{"226"}}, + {Code: "HU", Name: "匈牙利", MCCs: []string{"216"}}, + {Code: "UA", Name: "乌克兰", MCCs: []string{"255"}}, + {Code: "RU", Name: "俄罗斯", MCCs: []string{"250"}}, + {Code: "TR", Name: "土耳其", MCCs: []string{"286"}}, + {Code: "JP", Name: "日本", MCCs: []string{"440", "441"}}, + {Code: "KR", Name: "韩国", MCCs: []string{"450"}}, + {Code: "SG", Name: "新加坡", MCCs: []string{"525"}}, + {Code: "MY", Name: "马来西亚", MCCs: []string{"502"}}, + {Code: "TH", Name: "泰国", MCCs: []string{"520"}}, + {Code: "VN", Name: "越南", MCCs: []string{"452"}}, + {Code: "PH", Name: "菲律宾", MCCs: []string{"515"}}, + {Code: "ID", Name: "印度尼西亚", MCCs: []string{"510"}}, + {Code: "IN", Name: "印度", MCCs: []string{"404", "405", "406"}}, + {Code: "PK", Name: "巴基斯坦", MCCs: []string{"410"}}, + {Code: "AE", Name: "阿联酋", MCCs: []string{"424", "430", "431"}}, + {Code: "SA", Name: "沙特阿拉伯", MCCs: []string{"420"}}, + {Code: "IL", Name: "以色列", MCCs: []string{"425"}}, + {Code: "AU", Name: "澳大利亚", MCCs: []string{"505"}}, + {Code: "NZ", Name: "新西兰", MCCs: []string{"530"}}, + {Code: "BR", Name: "巴西", MCCs: []string{"724"}}, + {Code: "MX", Name: "墨西哥", MCCs: []string{"334"}}, + {Code: "AR", Name: "阿根廷", MCCs: []string{"722"}}, + {Code: "CL", Name: "智利", MCCs: []string{"730"}}, + {Code: "CO", Name: "哥伦比亚", MCCs: []string{"732"}}, + {Code: "ZA", Name: "南非", MCCs: []string{"655"}}, + {Code: "EG", Name: "埃及", MCCs: []string{"602"}}, + {Code: "NG", Name: "尼日利亚", MCCs: []string{"621"}}, + {Code: "KE", Name: "肯尼亚", MCCs: []string{"639"}}, +} diff --git a/internal/server/proxy_binding_test.go b/internal/server/proxy_binding_test.go new file mode 100644 index 0000000..65762b7 --- /dev/null +++ b/internal/server/proxy_binding_test.go @@ -0,0 +1,129 @@ +package server + +import ( + "bytes" + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "testing" + + "vocat/internal/store" +) + +func TestDeviceProxyBindingPersistsAndReconnectsEnabledVoWiFi(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "ec20", Name: "EC20", VoWiFiEnabled: true, + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ + ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true, + }); err != nil { + t.Fatal(err) + } + controller := &fakeVoWiFiController{} + server := &Server{ + store: database, + vowifi: controller, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + maxRequestBodyBytes: 4096, + } + + request := httptest.NewRequest( + http.MethodPut, + "/api/upstream-proxy-device-bindings/ec20", + bytes.NewBufferString(`{"upstream_proxy_id":"route-1"}`), + ) + request.Header.Set("Content-Type", "application/json") + response := httptest.NewRecorder() + server.handleDeviceProxyBinding(response, request, "ec20") + if response.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body = %s", response.Code, response.Body.String()) + } + binding, err := database.DeviceProxyBinding(context.Background(), "ec20") + if err != nil || binding.UpstreamProxyID != "route-1" { + t.Fatalf("binding = %+v, %v", binding, err) + } + if controller.reconnects != 1 { + t.Fatalf("reconnects = %d, want 1", controller.reconnects) + } + + request = httptest.NewRequest(http.MethodDelete, "/api/upstream-proxy-device-bindings/ec20", nil) + response = httptest.NewRecorder() + server.handleDeviceProxyBinding(response, request, "ec20") + if response.Code != http.StatusOK { + t.Fatalf("DELETE status = %d, body = %s", response.Code, response.Body.String()) + } + if _, err := database.DeviceProxyBinding(context.Background(), "ec20"); err != store.ErrNotFound { + t.Fatalf("binding after delete error = %v, want ErrNotFound", err) + } + if controller.reconnects != 2 { + t.Fatalf("reconnects = %d, want 2", controller.reconnects) + } +} + +func TestDeviceProxyBindingRejectsRebindToDifferentUpstream(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "ec20", Name: "EC20", VoWiFiEnabled: true, + }); err != nil { + t.Fatal(err) + } + for _, up := range []store.UpstreamProxy{ + {ID: "route-1", Name: "Route 1", Addr: "127.0.0.1:1080", Enabled: true}, + {ID: "route-2", Name: "Route 2", Addr: "127.0.0.1:1081", Enabled: true}, + } { + if err := database.UpsertUpstreamProxy(context.Background(), up); err != nil { + t.Fatal(err) + } + } + server := &Server{ + store: database, + vowifi: &fakeVoWiFiController{}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + maxRequestBodyBytes: 4096, + } + + // First bind to route-1 succeeds. + put := func(proxyID string) *httptest.ResponseRecorder { + req := httptest.NewRequest( + http.MethodPut, + "/api/upstream-proxy-device-bindings/ec20", + bytes.NewBufferString(`{"upstream_proxy_id":"`+proxyID+`"}`), + ) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + server.handleDeviceProxyBinding(rec, req, "ec20") + return rec + } + if rec := put("route-1"); rec.Code != http.StatusOK { + t.Fatalf("initial bind status = %d, body = %s", rec.Code, rec.Body.String()) + } + + // Rebind to a different upstream must be rejected with 409. + rec := put("route-2") + if rec.Code != http.StatusConflict { + t.Fatalf("rebind status = %d, want 409, body = %s", rec.Code, rec.Body.String()) + } + binding, err := database.DeviceProxyBinding(context.Background(), "ec20") + if err != nil || binding.UpstreamProxyID != "route-1" { + t.Fatalf("binding after rejected rebind = %+v, %v (want route-1 unchanged)", binding, err) + } + + // Re-binding the SAME upstream stays idempotent (no 409). + if rec := put("route-1"); rec.Code != http.StatusOK { + t.Fatalf("idempotent rebind status = %d, want 200, body = %s", rec.Code, rec.Body.String()) + } +} + diff --git a/internal/server/region_test.go b/internal/server/region_test.go new file mode 100644 index 0000000..5004620 --- /dev/null +++ b/internal/server/region_test.go @@ -0,0 +1,241 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "vocat/internal/device" + "vocat/internal/modem" + "vocat/internal/store" +) + +// fakeDeviceController is a stub DeviceController that resolves one discovered +// device carrying a fixed snapshot, enough to drive the region guards. The scan, +// USSD, and USB-net results are configurable for the feature endpoint tests. +type fakeDeviceController struct { + entry device.Device + scanResult device.OperatorScanResult + scanErr error + ussdResult device.USSDResult + ussdErr error + usbNetMode device.USBNetMode + usbNetErr error +} + +func (f fakeDeviceController) Discover(context.Context) ([]device.Device, error) { + return []device.Device{f.entry}, nil +} +func (f fakeDeviceController) List() []device.Device { return []device.Device{f.entry} } +func (f fakeDeviceController) Get(id string) (device.Device, error) { + if id == f.entry.ID { + return f.entry, nil + } + return device.Device{}, device.ErrNotFound +} +func (f fakeDeviceController) Refresh(context.Context, string) (device.Snapshot, error) { + return device.Snapshot{}, nil +} +func (f fakeDeviceController) ExecuteAT(context.Context, string, string) (modem.Response, error) { + return modem.Response{}, nil +} +func (f fakeDeviceController) Reboot(context.Context, string) error { return nil } +func (f fakeDeviceController) USSD(context.Context, string, string) (device.USSDResult, error) { + return device.USSDResult{}, nil +} +func (f fakeDeviceController) ContinueUSSD(context.Context, string, string) (device.USSDResult, error) { + return f.ussdResult, f.ussdErr +} +func (f fakeDeviceController) CancelUSSD(context.Context, string) error { return f.ussdErr } +func (f fakeDeviceController) SetFlight(context.Context, string, bool) (device.FlightResult, error) { + return device.FlightResult{}, nil +} +func (f fakeDeviceController) SetNetwork(context.Context, string, device.NetworkRequest) (device.NetworkResult, error) { + return device.NetworkResult{}, nil +} +func (f fakeDeviceController) USBNetMode(context.Context, string) (device.USBNetMode, error) { + return device.USBNetMode{}, nil +} +func (f fakeDeviceController) SetUSBNetMode(context.Context, string, int) (device.USBNetMode, error) { + return device.USBNetMode{}, nil +} +func (f fakeDeviceController) SetUSBNetModeByPort(context.Context, string, int) (device.USBNetMode, error) { + return f.usbNetMode, f.usbNetErr +} +func (f fakeDeviceController) OperatorSelection(context.Context, string) (device.OperatorSelection, error) { + return device.OperatorSelection{}, nil +} +func (f fakeDeviceController) SetOperatorSelection(context.Context, string, bool, string, *int) (device.OperatorSelection, error) { + return device.OperatorSelection{}, nil +} +func (f fakeDeviceController) ScanOperators(context.Context, string) (device.OperatorScanResult, error) { + return f.scanResult, f.scanErr +} +func (f fakeDeviceController) SendSMS(context.Context, string, string, string) (device.SMSSendResult, error) { + return device.SMSSendResult{}, nil +} +func (f fakeDeviceController) ListSMS(context.Context, string) ([]device.SMSMessage, error) { + return nil, nil +} +func (f fakeDeviceController) ReadSMS(context.Context, string, int) (device.SMSMessage, error) { + return device.SMSMessage{}, nil +} +func (f fakeDeviceController) DeleteSMS(context.Context, string, int) error { return nil } +func (f fakeDeviceController) ESIMListProfiles(context.Context, string) (device.EsimInfo, error) { + return device.EsimInfo{}, nil +} +func (f fakeDeviceController) ESIMInventory(context.Context, string) ([]device.EsimInventoryEntry, error) { + return []device.EsimInventoryEntry{}, nil +} +func (f fakeDeviceController) ESIMSwitchProfile(context.Context, string, string, string) error { + return nil +} +func (f fakeDeviceController) ESIMDisableProfile(context.Context, string, string, string) error { + return nil +} +func (f fakeDeviceController) ESIMRenameProfile(context.Context, string, string, string, string) error { + return nil +} +func (f fakeDeviceController) ESIMDownloadProfile(context.Context, string, device.EsimDownloadParams, func(device.EsimProgress)) (*device.EsimDownloadResult, error) { + return nil, errors.New("not implemented in test fake") +} +func (f fakeDeviceController) ESIMDeleteProfile(context.Context, string, string, string) (*device.EsimDeleteResult, error) { + return &device.EsimDeleteResult{}, nil +} +func (f fakeDeviceController) ESIMChipInfo(context.Context, string) (*device.EsimChipInfo, error) { + return nil, errors.New("not implemented in test fake") +} + +func regionTestLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func blockedRegionServer(t *testing.T, imsi string) *Server { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return &Server{ + store: database, + logger: regionTestLogger(), + maxRequestBodyBytes: 4096, + devices: fakeDeviceController{entry: device.Device{ + ID: "dev1", + Discovered: true, + Snapshot: &device.Snapshot{DeviceID: "dev1", IMSI: imsi}, + }}, + vowifi: &fakeVoWiFiController{}, + } +} + +func TestModemSummaryRegionFields(t *testing.T) { + t.Parallel() + blocked := modemSummary(&device.Snapshot{IMSI: "460001234567890"}, "", "") + if blocked["service_blocked"] != true { + t.Fatalf("service_blocked = %v, want true", blocked["service_blocked"]) + } + if blocked["card_mcc"] != "460" || blocked["card_country"] != "中国" { + t.Fatalf("card_mcc=%v card_country=%v", blocked["card_mcc"], blocked["card_country"]) + } + if reason, _ := blocked["blocked_reason"].(string); reason == "" { + t.Fatal("blocked_reason must be set for a blocked card") + } + + allowed := modemSummary(&device.Snapshot{IMSI: "310260123456789"}, "", "") + if allowed["service_blocked"] != false || allowed["blocked_reason"] != "" { + t.Fatalf("allowed card summary = %v / %v", allowed["service_blocked"], allowed["blocked_reason"]) + } + if allowed["card_mcc"] != "310" || allowed["card_country"] != "美国" { + t.Fatalf("allowed card_mcc=%v card_country=%v", allowed["card_mcc"], allowed["card_country"]) + } + + empty := modemSummary(nil, "", "") + if empty["service_blocked"] != false || empty["card_mcc"] != "" { + t.Fatalf("nil snapshot summary = %v / %v", empty["service_blocked"], empty["card_mcc"]) + } +} + +func TestWriteDeviceErrorMapsRegionBlockedTo403(t *testing.T) { + t.Parallel() + server := &Server{logger: regionTestLogger()} + recorder := httptest.NewRecorder() + server.writeDeviceError(recorder, device.ErrRegionBlocked) + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", recorder.Code) + } + var envelope errorEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatal(err) + } + if envelope.Error.Code != "region_blocked" { + t.Fatalf("error code = %q, want region_blocked", envelope.Error.Code) + } +} + +func TestCountryNameForMCC(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "460": "中国", + "461": "中国", + "454": "中国香港", + "466": "中国台湾", + "310": "美国", + "": "", + "999": "", + } + for mcc, want := range cases { + if got := countryNameForMCC(mcc); got != want { + t.Errorf("countryNameForMCC(%q) = %q, want %q", mcc, got, want) + } + } +} + +func TestHandleVoWiFiEnabledBlockedRegion(t *testing.T) { + server := blockedRegionServer(t, "460001234567890") + request := httptest.NewRequest(http.MethodPatch, "/devices/dev1/vowifi", strings.NewReader(`{"enabled":true}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + config := store.Device{ID: "dev1"} + if handled := server.handleVoWiFiEnabled(recorder, request, config, true); !handled { + t.Fatal("handler did not claim the request") + } + if recorder.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", recorder.Code) + } + var envelope errorEnvelope + if err := json.NewDecoder(recorder.Body).Decode(&envelope); err != nil { + t.Fatal(err) + } + if envelope.Error.Code != "region_blocked" { + t.Fatalf("error code = %q, want region_blocked", envelope.Error.Code) + } + // The block must happen before any state change is persisted. + if _, err := server.store.Device(context.Background(), "dev1"); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("device config must not be written for a blocked region, err=%v", err) + } +} + +func TestHandleVoWiFiEnabledAllowedRegionPassesGuard(t *testing.T) { + server := blockedRegionServer(t, "310260123456789") + // Seed the device so the post-guard UpsertDevice succeeds. + if err := server.store.UpsertDevice(context.Background(), store.Device{ID: "dev1", Name: "EC20"}); err != nil { + t.Fatalf("seed device: %v", err) + } + request := httptest.NewRequest(http.MethodPatch, "/devices/dev1/vowifi", strings.NewReader(`{"enabled":true}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + + server.handleVoWiFiEnabled(recorder, request, store.Device{ID: "dev1"}, true) + if recorder.Code == http.StatusForbidden { + t.Fatalf("allowed region must not be blocked, got 403: %s", recorder.Body.String()) + } +} diff --git a/internal/server/security_settings_test.go b/internal/server/security_settings_test.go new file mode 100644 index 0000000..450481a --- /dev/null +++ b/internal/server/security_settings_test.go @@ -0,0 +1,235 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "vocat/internal/store" +) + +func TestParseAccessConfigValidation(t *testing.T) { + if _, err := parseAccessConfig(accessConfig{Mode: "bogus"}); err == nil { + t.Fatal("accepted an invalid mode") + } + if _, err := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"not-a-cidr"}}); err == nil { + t.Fatal("accepted an invalid CIDR") + } + parsed, err := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"203.0.113.0/24", "198.51.100.7"}}) + if err != nil { + t.Fatalf("parseAccessConfig: %v", err) + } + if len(parsed.cidrs) != 2 { + t.Fatalf("cidrs = %v", parsed.cidrs) + } +} + +func TestAccessControlMiddleware(t *testing.T) { + server := &Server{logger: regionTestLogger()} + ok := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + handler := server.accessControl(ok) + + check := func(config parsedAccessConfig, remoteAddr string, forwardedFor string) int { + server.accessMu.Lock() + server.access = config + server.accessMu.Unlock() + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = remoteAddr + if forwardedFor != "" { + req.Header.Set("X-Forwarded-For", forwardedFor) + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder.Code + } + + internal := parsedAccessConfig{mode: "internal"} + if got := check(internal, "192.168.2.10:5000", ""); got != http.StatusOK { + t.Fatalf("private IP denied: %d", got) + } + if got := check(internal, "127.0.0.1:5000", ""); got != http.StatusOK { + t.Fatalf("loopback denied: %d", got) + } + if got := check(internal, "8.8.8.8:5000", ""); got != http.StatusForbidden { + t.Fatalf("public IP allowed in internal mode: %d", got) + } + // Custom CIDR admits an otherwise-public range. + withCIDR := parsedAccessConfig{mode: "internal"} + parsed, _ := parseAccessConfig(accessConfig{Mode: "internal", AllowedCIDRs: []string{"8.8.8.0/24"}}) + withCIDR = parsed + if got := check(withCIDR, "8.8.8.8:5000", ""); got != http.StatusOK { + t.Fatalf("custom CIDR not honored: %d", got) + } + // Public mode allows anything. + public := parsedAccessConfig{mode: "public"} + if got := check(public, "8.8.8.8:5000", ""); got != http.StatusOK { + t.Fatalf("public mode denied a public IP: %d", got) + } + // Proxy headers are ignored unless explicitly trusted. + trust := parsedAccessConfig{mode: "internal", trustProxy: true} + if got := check(trust, "8.8.8.8:5000", "192.168.1.20"); got != http.StatusOK { + t.Fatalf("trusted X-Forwarded-For not honored: %d", got) + } + if got := check(internal, "8.8.8.8:5000", "192.168.1.20"); got != http.StatusForbidden { + t.Fatalf("untrusted X-Forwarded-For was honored: %d", got) + } +} + +func TestLoginRateLimiterLocksAndResets(t *testing.T) { + limiter := newLoginRateLimiter() + now := time.Now() + limiter.now = func() time.Time { return now } + key := "192.168.1.1|admin" + + for i := 0; i < limiter.maxFailures-1; i++ { + if _, locked := limiter.recordFailure(key); locked { + t.Fatalf("locked after %d failures, below threshold", i+1) + } + } + if _, locked := limiter.recordFailure(key); !locked { + t.Fatal("not locked at the failure threshold") + } + if _, locked := limiter.checkLocked(key); !locked { + t.Fatal("checkLocked did not report the lock") + } + // Success clears the track record. + limiter.recordSuccess(key) + if _, locked := limiter.checkLocked(key); locked { + t.Fatal("still locked after a success") + } + // Lockout expires after the lockout duration. + for i := 0; i < limiter.maxFailures; i++ { + limiter.recordFailure(key) + } + now = now.Add(limiter.lockout + time.Second) + if _, locked := limiter.checkLocked(key); locked { + t.Fatal("lock did not expire after the lockout window") + } +} + +func newSettingsTestServer(t *testing.T) *Server { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + return &Server{ + store: database, + logger: regionTestLogger(), + maxRequestBodyBytes: 4096, + access: defaultAccessConfig(), + } +} + +func TestHandleSecuritySettingsRoundTrip(t *testing.T) { + server := newSettingsTestServer(t) + body := `{"mode":"internal","allowed_cidrs":["203.0.113.0/24"],"trust_proxy_headers":true}` + request := httptest.NewRequest(http.MethodPut, "/api/settings/security", strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.RemoteAddr = "192.168.2.20:5000" + recorder := httptest.NewRecorder() + server.handleSecuritySettings(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + if server.currentAccessConfig().trustProxy != true { + t.Fatal("runtime access config was not updated") + } + // Persisted? + setting, err := server.store.AppSetting(context.Background(), accessSettingKey) + if err != nil || !strings.Contains(string(setting.Value), "203.0.113.0/24") { + t.Fatalf("access policy not persisted: %v %v", setting, err) + } + // GET reflects it. + getRec := httptest.NewRecorder() + getReq := httptest.NewRequest(http.MethodGet, "/api/settings/security", nil) + getReq.RemoteAddr = "192.168.2.20:5000" + server.handleSecuritySettings(getRec, getReq) + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.NewDecoder(getRec.Body).Decode(&envelope); err != nil { + t.Fatal(err) + } + if envelope.Data["trust_proxy_headers"] != true || envelope.Data["client_allowed"] != true { + t.Fatalf("GET data = %v", envelope.Data) + } +} + +func TestHandleSecuritySettingsRejectsBadPolicy(t *testing.T) { + server := newSettingsTestServer(t) + request := httptest.NewRequest(http.MethodPut, "/api/settings/security", strings.NewReader(`{"mode":"nowhere"}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.handleSecuritySettings(recorder, request) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", recorder.Code) + } +} + +func TestHandleLoggingSettingsRoundTripAndEnforceCount(t *testing.T) { + server := newSettingsTestServer(t) + // Seed 10 log rows. + for i := 0; i < 10; i++ { + if _, err := server.store.AppendLogEvent(context.Background(), store.LogEvent{ + Level: "info", Message: "entry", + }); err != nil { + t.Fatal(err) + } + } + // Keep only the newest 4. + request := httptest.NewRequest(http.MethodPut, "/api/settings/logging", strings.NewReader(`{"mode":"count","count":4}`)) + request.Header.Set("Content-Type", "application/json") + recorder := httptest.NewRecorder() + server.handleLoggingSettings(recorder, request) + if recorder.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body=%s", recorder.Code, recorder.Body.String()) + } + count, err := server.store.CountLogEvents(context.Background()) + if err != nil { + t.Fatal(err) + } + if count != 4 { + t.Fatalf("stored log count = %d, want 4 after retention", count) + } +} + +func TestLoginLockoutViaHTTP(t *testing.T) { + app := newTestApplication(t) + for i := 0; i < 4; i++ { + response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", + strings.NewReader(`{"username":"admin","password":"wrong"}`)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("attempt %d status = %d, want 401", i+1, response.StatusCode) + } + } + // Fifth consecutive failure crosses the threshold and locks. + response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", + strings.NewReader(`{"username":"admin","password":"wrong"}`)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusTooManyRequests { + t.Fatalf("5th failure status = %d, want 429", response.StatusCode) + } + // Even the correct password is refused while locked. + response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", + strings.NewReader(`{"username":"admin","password":"correct-password"}`)) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusTooManyRequests { + t.Fatalf("locked login status = %d, want 429", response.StatusCode) + } +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..51a755d --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,569 @@ +package server + +import ( + "bytes" + "context" + "crypto/subtle" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "log/slog" + "mime" + "net/http" + "path" + "strings" + "sync" + "time" + + "vocat/internal/auth" + "vocat/internal/loghub" + "vocat/internal/store" + "vocat/internal/vowifi" +) + +const ( + sessionCookieName = "vocat_session" + csrfCookieName = "vocat_csrf" + csrfHeaderName = "X-CSRF-Token" +) + +type Options struct { + Store *store.Store + Auth *auth.Service + Devices DeviceController + VoWiFi VoWiFiController + Logs *loghub.Hub + Assets fs.FS + Logger *slog.Logger + SecureCookies bool + MaxRequestBodyBytes int64 +} + +// Server is the single HTTP handler for the JSON API and embedded SPA. +type Server struct { + store *store.Store + auth *auth.Service + devices DeviceController + vowifi VoWiFiController + logs *loghub.Hub + assets fs.FS + indexHTML []byte + fileServer http.Handler + logger *slog.Logger + secureCookies bool + maxRequestBodyBytes int64 + startedAt time.Time + handler http.Handler + websheets *websheetManager + accessMu sync.RWMutex + access parsedAccessConfig + loginLimiter *loginRateLimiter +} + +func New(options Options) (*Server, error) { + if options.Store == nil { + return nil, errors.New("server: store is required") + } + if options.Auth == nil { + return nil, errors.New("server: auth service is required") + } + if options.Assets == nil { + return nil, errors.New("server: SPA assets are required") + } + indexHTML, err := fs.ReadFile(options.Assets, "index.html") + if err != nil { + return nil, fmt.Errorf("server: read embedded index.html: %w", err) + } + if options.Logger == nil { + options.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + } + if options.MaxRequestBodyBytes <= 0 { + options.MaxRequestBodyBytes = 1 << 20 + } + + server := &Server{ + store: options.Store, + auth: options.Auth, + devices: options.Devices, + vowifi: options.VoWiFi, + logs: options.Logs, + assets: options.Assets, + indexHTML: indexHTML, + fileServer: http.FileServer(http.FS(options.Assets)), + logger: options.Logger, + secureCookies: options.SecureCookies, + maxRequestBodyBytes: options.MaxRequestBodyBytes, + startedAt: time.Now().UTC(), + websheets: newWebsheetManager(), + loginLimiter: newLoginRateLimiter(), + } + server.loadAccessConfig(context.Background()) + server.loadUILanguage(context.Background()) + + mux := http.NewServeMux() + mux.HandleFunc("/api/health", server.handleHealth) + mux.HandleFunc("/api/auth/login", server.handleLogin) + mux.HandleFunc("/api/auth/session", server.handleSession) + mux.HandleFunc("/api/auth/logout", server.handleLogout) + mux.HandleFunc("/api", server.handleAPI) + mux.HandleFunc("/api/", server.handleAPI) + mux.HandleFunc("/websheets/", server.handleWebsheet) + mux.HandleFunc("/", server.handleSPA) + + server.handler = server.recoverPanics( + server.securityHeaders(server.accessControl(server.logRequests(mux))), + ) + return server, nil +} + +// VoWiFiController is the asynchronous runtime boundary used by the HTTP +// layer. State transitions continue after the request completes and are +// surfaced by the normal device status endpoints. +type VoWiFiController interface { + State(string) (vowifi.State, error) + RequestEnabled(string, bool) (vowifi.State, error) + RequestReconnect(string) (vowifi.State, error) +} + +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.handler.ServeHTTP(w, r) +} + +func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second) + defer cancel() + if err := s.store.Ready(ctx); err != nil { + s.logger.Error("health check failed", "error", err) + writeError(w, http.StatusServiceUnavailable, "unavailable", "service is not ready") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "status": "ok", + "database": "ok", + "time": time.Now().UTC().Format(time.RFC3339), + }, + }) +} + +func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !requireMethod(w, r, http.MethodPost) { + return + } + var request struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + + limiterKey := s.loginKey(r, request.Username) + if retryAfter, locked := s.loginLimiter.checkLocked(limiterKey); locked { + s.auditAuth(r, request.Username, "locked") + w.Header().Set("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds())+1)) + writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later") + return + } + + credentials, err := s.auth.Login(r.Context(), request.Username, request.Password) + if errors.Is(err, auth.ErrInvalidCredentials) { + lockout, newlyLocked := s.loginLimiter.recordFailure(limiterKey) + s.auditAuth(r, request.Username, "failure") + if newlyLocked { + w.Header().Set("Retry-After", fmt.Sprintf("%d", int(lockout.Seconds()))) + writeError(w, http.StatusTooManyRequests, "too_many_attempts", "too many failed login attempts; please try again later") + return + } + writeError(w, http.StatusUnauthorized, "invalid_credentials", "invalid username or password") + return + } + if err != nil { + s.logger.Error("login failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + return + } + + s.loginLimiter.recordSuccess(limiterKey) + s.auditAuth(r, credentials.Principal.Username, "success") + s.setAuthCookies(w, credentials.SessionToken, credentials.CSRFToken, credentials.ExpiresAt) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "user": credentials.Principal, + "csrf_token": credentials.CSRFToken, + "expires_at": credentials.ExpiresAt.Format(time.RFC3339), + "authenticated": true, + "status": "ok", + }, + }) +} + +// loginKey builds the rate-limit key from the client address and username so +// brute-force attempts against one account from one source are throttled. +func (s *Server) loginKey(r *http.Request, username string) string { + address := s.currentAccessConfig().clientIP(r) + return address.String() + "|" + strings.ToLower(strings.TrimSpace(username)) +} + +func (s *Server) handleSession(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !requireMethod(w, r, http.MethodGet) { + return + } + sessionToken, ok := s.sessionToken(w, r) + if !ok { + return + } + existingCSRF := "" + if cookie, cookieErr := r.Cookie(csrfCookieName); cookieErr == nil { + existingCSRF = cookie.Value + } + session, csrfToken, err := s.auth.CSRFToken(r.Context(), sessionToken, existingCSRF) + if errors.Is(err, auth.ErrUnauthorized) { + s.clearAuthCookies(w) + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required") + return + } + if err != nil { + s.logger.Error("load session failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + return + } + s.setCSRFCookie(w, csrfToken, session.ExpiresAt) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "user": session.Principal, + "csrf_token": csrfToken, + "expires_at": session.ExpiresAt.Format(time.RFC3339), + "authenticated": true, + }, + }) +} + +func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if !requireMethod(w, r, http.MethodPost) { + return + } + sessionToken, ok := s.sessionToken(w, r) + if !ok { + return + } + csrfToken, ok := s.validateDoubleSubmitCSRF(w, r) + if !ok { + return + } + if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil { + switch { + case errors.Is(err, auth.ErrUnauthorized): + s.clearAuthCookies(w) + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required") + case errors.Is(err, auth.ErrInvalidCSRF): + writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed") + default: + s.logger.Error("logout validation failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + } + return + } + if err := s.auth.Logout(r.Context(), sessionToken); err != nil { + s.logger.Error("logout failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + return + } + s.clearAuthCookies(w) + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]bool{"logged_out": true}, + }) +} + +func (s *Server) handleAPINotFound(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "not_found", "API endpoint not found") +} + +func (s *Server) handleAPI(w http.ResponseWriter, r *http.Request) { + // The UI language preference is not sensitive; exposing the read side lets + // the login page render in the persisted language before authentication. + if r.Method == http.MethodGet && + strings.Trim(strings.TrimPrefix(r.URL.Path, "/api"), "/") == "settings/preferences" { + s.writeUIPreferences(w, r) + return + } + if !s.requireAuthenticated(w, r) { + return + } + if r.Method != http.MethodGet && + r.Method != http.MethodHead && + r.Method != http.MethodOptions { + sessionToken, ok := s.sessionToken(w, r) + if !ok { + return + } + csrfToken, ok := s.validateDoubleSubmitCSRF(w, r) + if !ok { + return + } + if _, err := s.auth.ValidateCSRF(r.Context(), sessionToken, csrfToken); err != nil { + switch { + case errors.Is(err, auth.ErrUnauthorized): + s.clearAuthCookies(w) + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required") + case errors.Is(err, auth.ErrInvalidCSRF): + writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed") + default: + s.logger.Error("API CSRF validation failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + } + return + } + } + if s.routeDeviceAPI(w, r) { + return + } + if s.routeGeneralAPI(w, r) { + return + } + s.handleAPINotFound(w, r) +} + +func (s *Server) handleSPA(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return + } + + name := strings.TrimPrefix(path.Clean(r.URL.Path), "/") + if name != "." && fs.ValidPath(name) { + if info, err := fs.Stat(s.assets, name); err == nil && info.Mode().IsRegular() { + if strings.HasPrefix(name, "assets/") { + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") + } + s.fileServer.ServeHTTP(w, r) + return + } + } + + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Content-Type", mime.TypeByExtension(".html")) + http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(s.indexHTML)) +} + +func (s *Server) decodeJSON(w http.ResponseWriter, r *http.Request, destination any) error { + contentType := r.Header.Get("Content-Type") + if contentType != "" { + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || + (mediaType != "application/json" && !strings.HasSuffix(mediaType, "+json")) { + return errors.New("Content-Type must be application/json") + } + } + + r.Body = http.MaxBytesReader(w, r.Body, s.maxRequestBodyBytes) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(destination); err != nil { + var maxBytesError *http.MaxBytesError + if errors.As(err, &maxBytesError) { + return fmt.Errorf("request body exceeds %d bytes", s.maxRequestBodyBytes) + } + return errors.New("request body must contain one valid JSON object") + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + return errors.New("request body must contain one valid JSON object") + } + return nil +} + +func (s *Server) sessionToken(w http.ResponseWriter, r *http.Request) (string, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil || cookie.Value == "" { + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required") + return "", false + } + return cookie.Value, true +} + +func (s *Server) requireAuthenticated(w http.ResponseWriter, r *http.Request) bool { + sessionToken, ok := s.sessionToken(w, r) + if !ok { + return false + } + if _, err := s.auth.Authenticate(r.Context(), sessionToken); err != nil { + if errors.Is(err, auth.ErrUnauthorized) { + s.clearAuthCookies(w) + writeError(w, http.StatusUnauthorized, "unauthorized", "authentication is required") + } else { + s.logger.Error("request authentication failed", "error", err) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + } + return false + } + return true +} + +func (s *Server) validateDoubleSubmitCSRF(w http.ResponseWriter, r *http.Request) (string, bool) { + headerToken := r.Header.Get(csrfHeaderName) + cookie, err := r.Cookie(csrfCookieName) + if err != nil || headerToken == "" || cookie.Value == "" || + subtle.ConstantTimeCompare([]byte(headerToken), []byte(cookie.Value)) != 1 { + writeError(w, http.StatusForbidden, "invalid_csrf", "CSRF validation failed") + return "", false + } + return headerToken, true +} + +func (s *Server) setAuthCookies(w http.ResponseWriter, sessionToken string, csrfToken string, expiresAt time.Time) { + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge < 1 { + maxAge = 1 + } + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: sessionToken, + Path: "/", + Expires: expiresAt, + MaxAge: maxAge, + HttpOnly: true, + Secure: s.secureCookies, + SameSite: http.SameSiteStrictMode, + }) + s.setCSRFCookie(w, csrfToken, expiresAt) +} + +func (s *Server) setCSRFCookie(w http.ResponseWriter, csrfToken string, expiresAt time.Time) { + maxAge := int(time.Until(expiresAt).Seconds()) + if maxAge < 1 { + maxAge = 1 + } + http.SetCookie(w, &http.Cookie{ + Name: csrfCookieName, + Value: csrfToken, + Path: "/", + Expires: expiresAt, + MaxAge: maxAge, + HttpOnly: false, + Secure: s.secureCookies, + SameSite: http.SameSiteStrictMode, + }) +} + +func (s *Server) clearAuthCookies(w http.ResponseWriter) { + for _, name := range []string{sessionCookieName, csrfCookieName} { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: "/", + Expires: time.Unix(1, 0), + MaxAge: -1, + HttpOnly: name == sessionCookieName, + Secure: s.secureCookies, + SameSite: http.SameSiteStrictMode, + }) + } +} + +func requireMethod(w http.ResponseWriter, r *http.Request, allowed string) bool { + if r.Method == allowed { + return true + } + w.Header().Set("Allow", allowed) + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + return false +} + +type statusWriter struct { + http.ResponseWriter + status int +} + +func (w *statusWriter) Unwrap() http.ResponseWriter { + return w.ResponseWriter +} + +func (w *statusWriter) WriteHeader(status int) { + if w.status != 0 { + return + } + w.status = status + w.ResponseWriter.WriteHeader(status) +} + +func (w *statusWriter) Write(data []byte) (int, error) { + if w.status == 0 { + w.WriteHeader(http.StatusOK) + } + return w.ResponseWriter.Write(data) +} + +func (s *Server) logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startedAt := time.Now() + writer := &statusWriter{ResponseWriter: w} + next.ServeHTTP(writer, r) + status := writer.status + if status == 0 { + status = http.StatusOK + } + s.logger.Info( + "http request", + "method", r.Method, + "path", r.URL.Path, + "status", status, + "duration", time.Since(startedAt), + ) + }) +} + +func (s *Server) securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "same-origin") + w.Header().Set("Permissions-Policy", "camera=(), microphone=(), geolocation=()") + if strings.HasPrefix(r.URL.Path, "/websheets/") { + // The self-hosted E911 websheet is embedded in an iframe by the SPA, so + // it must be frameable same-origin. Every other route stays DENY. + w.Header().Set("X-Frame-Options", "SAMEORIGIN") + w.Header().Set( + "Content-Security-Policy", + "default-src 'self'; base-uri 'self'; frame-ancestors 'self'; "+ + "object-src 'none'; form-action 'self'; "+ + "script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+ + "img-src 'self' data:; connect-src 'self'", + ) + } else { + w.Header().Set("X-Frame-Options", "DENY") + w.Header().Set( + "Content-Security-Policy", + "default-src 'self'; base-uri 'self'; frame-ancestors 'none'; "+ + "object-src 'none'; form-action 'self'; "+ + "script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self'; "+ + "img-src 'self' data:; connect-src 'self'", + ) + } + if s.secureCookies { + w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains") + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) recoverPanics(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + if recovered := recover(); recovered != nil { + s.logger.Error("panic while serving request", "panic", recovered) + writeError(w, http.StatusInternalServerError, "internal_error", "an internal error occurred") + } + }() + next.ServeHTTP(w, r) + }) +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..da6c2d7 --- /dev/null +++ b/internal/server/server_test.go @@ -0,0 +1,373 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "io/fs" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "strconv" + "strings" + "testing" + "testing/fstest" + "time" + + "golang.org/x/crypto/bcrypt" + + "vocat/internal/auth" + "vocat/internal/store" +) + +type testApplication struct { + server *httptest.Server + client *http.Client +} + +func newTestApplication(t *testing.T) testApplication { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatalf("store.Open() error = %v", err) + } + t.Cleanup(func() { + _ = database.Close() + }) + authService, err := auth.New(database, auth.Options{ + SessionTTL: time.Hour, + BcryptCost: bcrypt.MinCost, + }) + if err != nil { + t.Fatal(err) + } + if err := authService.EnsureAdmin(context.Background(), "admin", "correct-password"); err != nil { + t.Fatal(err) + } + assets := fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte("SPA shell")}, + "assets/app.js": &fstest.MapFile{Data: []byte("console.log('ok')")}, + } + handler, err := New(Options{ + Store: database, + Auth: authService, + Assets: assets, + MaxRequestBodyBytes: 4096, + }) + if err != nil { + t.Fatal(err) + } + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + jar, err := cookiejar.New(nil) + if err != nil { + t.Fatal(err) + } + return testApplication{ + server: httpServer, + client: &http.Client{Jar: jar}, + } +} + +func TestHealthAndSPAFallback(t *testing.T) { + app := newTestApplication(t) + + response, err := app.client.Get(app.server.URL + "/api/health") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("health status = %d", response.StatusCode) + } + if response.Header.Get("X-Content-Type-Options") != "nosniff" { + t.Fatal("security headers not present") + } + if response.Header.Get("Access-Control-Allow-Origin") != "" { + t.Fatal("CORS must not be enabled") + } + + response, err = app.client.Get(app.server.URL + "/settings/deep/link") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, _ := io.ReadAll(response.Body) + if !bytes.Contains(body, []byte("SPA shell")) { + t.Fatalf("SPA fallback body = %q", body) + } + + response, err = app.client.Get(app.server.URL + "/assets/app.js") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.Header.Get("Cache-Control") != "public, max-age=31536000, immutable" { + t.Fatalf("asset Cache-Control = %q", response.Header.Get("Cache-Control")) + } +} + +func TestLoginSessionCSRFAndLogout(t *testing.T) { + app := newTestApplication(t) + + loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`) + response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody) + if err != nil { + t.Fatal(err) + } + var loginResponse struct { + Data struct { + CSRFToken string `json:"csrf_token"` + } `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" { + t.Fatalf("login status = %d, body = %+v", response.StatusCode, loginResponse) + } + var sessionCookie *http.Cookie + for _, cookie := range response.Cookies() { + if cookie.Name == sessionCookieName { + sessionCookie = cookie + } + } + if sessionCookie == nil || !sessionCookie.HttpOnly || sessionCookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("invalid session cookie: %+v", sessionCookie) + } + + response, err = app.client.Get(app.server.URL + "/api/auth/session") + if err != nil { + t.Fatal(err) + } + var sessionResponse struct { + Data struct { + CSRFToken string `json:"csrf_token"` + } `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&sessionResponse); err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK || sessionResponse.Data.CSRFToken == "" { + t.Fatalf("session status = %d, body = %+v", response.StatusCode, sessionResponse) + } + + request, err := http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil) + if err != nil { + t.Fatal(err) + } + response, err = app.client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusForbidden { + t.Fatalf("logout without CSRF status = %d", response.StatusCode) + } + + request, err = http.NewRequest(http.MethodPost, app.server.URL+"/api/auth/logout", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set(csrfHeaderName, sessionResponse.Data.CSRFToken) + response, err = app.client.Do(request) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("logout status = %d", response.StatusCode) + } + + response, err = app.client.Get(app.server.URL + "/api/auth/session") + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("session after logout status = %d", response.StatusCode) + } +} + +func TestUnifiedAPIErrors(t *testing.T) { + app := newTestApplication(t) + + response, err := app.client.Get(app.server.URL + "/api/not-present") + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d", response.StatusCode) + } + response.Body.Close() + + loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`) + response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK { + t.Fatalf("login status = %d", response.StatusCode) + } + + response, err = app.client.Get(app.server.URL + "/api/not-present") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusNotFound { + t.Fatalf("authenticated not-found status = %d", response.StatusCode) + } + var envelope errorEnvelope + if err := json.NewDecoder(response.Body).Decode(&envelope); err != nil { + t.Fatal(err) + } + if envelope.Error.Code != "not_found" { + t.Fatalf("error = %+v", envelope.Error) + } + + badLogin := bytes.NewBufferString(`{"username":"admin","password":"wrong","extra":true}`) + response, err = app.client.Post(app.server.URL+"/api/auth/login", "application/json", badLogin) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("invalid JSON status = %d", response.StatusCode) + } +} + +func TestNewRequiresIndex(t *testing.T) { + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + defer database.Close() + authService, err := auth.New(database, auth.Options{ + SessionTTL: time.Hour, + BcryptCost: bcrypt.MinCost, + }) + if err != nil { + t.Fatal(err) + } + if _, err := New(Options{ + Store: database, + Auth: authService, + Assets: fs.FS(fstest.MapFS{}), + }); err == nil { + t.Fatal("New() unexpectedly accepted assets without index.html") + } +} + +func TestSecureCookieAttributes(t *testing.T) { + recorder := httptest.NewRecorder() + server := &Server{secureCookies: true} + server.setAuthCookies( + recorder, + "session-token", + "csrf-token", + time.Now().Add(time.Hour), + ) + + var sessionCookie *http.Cookie + var csrfCookie *http.Cookie + for _, cookie := range recorder.Result().Cookies() { + switch cookie.Name { + case sessionCookieName: + sessionCookie = cookie + case csrfCookieName: + csrfCookie = cookie + } + } + if sessionCookie == nil || !sessionCookie.HttpOnly || !sessionCookie.Secure || + sessionCookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("invalid session cookie: %+v", sessionCookie) + } + if csrfCookie == nil || csrfCookie.HttpOnly || !csrfCookie.Secure || + csrfCookie.SameSite != http.SameSiteStrictMode { + t.Fatalf("invalid CSRF cookie: %+v", csrfCookie) + } +} + +func TestUIPreferencesDefaultPublicReadAndPersistedWrite(t *testing.T) { + app := newTestApplication(t) + + readLanguage := func() (int, string) { + response, err := app.client.Get(app.server.URL + "/api/settings/preferences") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var body struct { + Data struct { + Language string `json:"language"` + } `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatal(err) + } + return response.StatusCode, body.Data.Language + } + + status, language := readLanguage() + if status != http.StatusOK || language != "en" { + t.Fatalf("default preferences = %d %q", status, language) + } + + putLanguage := func(value string, csrf string) int { + request, err := http.NewRequest( + http.MethodPut, + app.server.URL+"/api/settings/preferences", + strings.NewReader(`{"language":`+strconv.Quote(value)+`}`), + ) + if err != nil { + t.Fatal(err) + } + request.Header.Set("Content-Type", "application/json") + if csrf != "" { + request.Header.Set(csrfHeaderName, csrf) + } + response, err := app.client.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + return response.StatusCode + } + + if status := putLanguage("zh", ""); status != http.StatusUnauthorized { + t.Fatalf("unauthenticated write status = %d", status) + } + + loginBody := bytes.NewBufferString(`{"username":"admin","password":"correct-password"}`) + response, err := app.client.Post(app.server.URL+"/api/auth/login", "application/json", loginBody) + if err != nil { + t.Fatal(err) + } + var loginResponse struct { + Data struct { + CSRFToken string `json:"csrf_token"` + } `json:"data"` + } + if err := json.NewDecoder(response.Body).Decode(&loginResponse); err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode != http.StatusOK || loginResponse.Data.CSRFToken == "" { + t.Fatalf("login status = %d", response.StatusCode) + } + + if status := putLanguage("fr", loginResponse.Data.CSRFToken); status != http.StatusBadRequest { + t.Fatalf("invalid language status = %d", status) + } + if status := putLanguage("zh", loginResponse.Data.CSRFToken); status != http.StatusOK { + t.Fatalf("write status = %d", status) + } + if status, language := readLanguage(); status != http.StatusOK || language != "zh" { + t.Fatalf("persisted preferences = %d %q", status, language) + } +} diff --git a/internal/server/settings_api.go b/internal/server/settings_api.go new file mode 100644 index 0000000..034823a --- /dev/null +++ b/internal/server/settings_api.go @@ -0,0 +1,1333 @@ +package server + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/tls" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/mail" + "net/netip" + "net/smtp" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "vocat/internal/store" +) + +var ( + errUnsafeDestination = errors.New("notification destination is not public") + errProviderRejected = errors.New("notification provider rejected the test") + telegramTokenPattern = regexp.MustCompile(`^[0-9]{5,20}:[A-Za-z0-9_-]{20,128}$`) +) + +var notificationChannels = []string{ + "telegram", + "email", + "webhook", + "bark", + "pushplus", +} + +var notificationFields = map[string]map[string]string{ + "telegram": { + "bot_token": "string", "chat_id": "string", "admin_id": "string", + "base_url": "string", "proxy": "string", + }, + "email": { + "use_ssl": "boolean", "smtp_host": "string", "smtp_port": "integer", "username": "string", + "password": "string", "from_address": "string", "to_addresses": "strings", + }, + "webhook": { + "urls": "strings", "secret": "string", "timeout_ms": "integer", + "retry_max": "integer", "text_template": "string", "headers": "string_map", + }, + "bark": { + "urls": "strings", "group": "string", "icon": "string", "level": "string", + }, + "pushplus": { + "token": "string", "topic": "string", "channel": "string", + }, +} + +// routeSettingsAPI is intentionally independent of the main router so it can +// be wired after the surrounding authentication and CSRF checks. +func (s *Server) routeSettingsAPI( + w http.ResponseWriter, + r *http.Request, + cleanPath string, +) bool { + cleanPath = strings.Trim(cleanPath, "/") + switch cleanPath { + case "settings/notifications": + s.handleNotificationSettings(w, r) + return true + case "traffic/analysis": + s.handleTrafficAnalysis(w, r) + return true + case "cards/policies": + s.handleCardPolicies(w, r) + return true + case "settings/security": + s.handleSecuritySettings(w, r) + return true + case "settings/logging": + s.handleLoggingSettings(w, r) + return true + } + segments := splitAPIPath(cleanPath) + if len(segments) == 4 && + segments[0] == "settings" && + segments[1] == "notifications" && + segments[3] == "test" { + s.handleNotificationTest(w, r, segments[2]) + return true + } + if len(segments) == 3 && segments[0] == "cards" && segments[2] == "policy" { + s.handleCardPolicy(w, r, segments[1]) + return true + } + return false +} + +func (s *Server) handleNotificationSettings(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + s.writeNotificationSettings(w, r) + case http.MethodPut: + var request map[string]json.RawMessage + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if request == nil { + writeError(w, http.StatusBadRequest, "invalid_request", "request body must be a JSON object") + return + } + values := make([]store.NotificationSetting, 0, len(request)) + for _, channel := range notificationChannels { + raw, present := request[channel] + if !present { + continue + } + enabled, config, err := decodeNotificationConfig(channel, raw, true) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) + return + } + values = append(values, store.NotificationSetting{ + Channel: channel, + Enabled: enabled, + Config: config, + SensitiveFields: store.DefaultNotificationSensitiveFields(channel), + }) + } + for channel := range request { + if !knownNotificationChannel(channel) { + writeError( + w, + http.StatusBadRequest, + "invalid_notification_channel", + fmt.Sprintf("unsupported notification channel %q", channel), + ) + return + } + } + if err := s.store.SaveNotificationSettings(r.Context(), values); err != nil { + s.writeStoreError(w, err) + return + } + s.writeNotificationSettings(w, r) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func (s *Server) writeNotificationSettings(w http.ResponseWriter, r *http.Request) { + settings, err := s.store.ListNotificationSettings(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + stored := make(map[string]store.NotificationSetting, len(settings)) + for _, setting := range settings { + stored[setting.Channel] = setting + } + response := make(map[string]any, len(notificationChannels)) + for _, channel := range notificationChannels { + document := map[string]any{"enabled": false} + if setting, ok := stored[channel]; ok { + redacted := setting.Redacted() + if err := json.Unmarshal(redacted.Config, &document); err != nil { + s.logger.Error( + "notification setting contains invalid JSON", + "channel", + channel, + "error", + err, + ) + writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed") + return + } + document["enabled"] = setting.Enabled + } + response[channel] = document + } + writeJSON(w, http.StatusOK, map[string]any{"data": response}) +} + +func decodeNotificationConfig( + channel string, + raw json.RawMessage, + requireEnabled bool, +) (bool, json.RawMessage, error) { + if !knownNotificationChannel(channel) { + return false, nil, fmt.Errorf("unsupported notification channel %q", channel) + } + var document map[string]json.RawMessage + if err := json.Unmarshal(raw, &document); err != nil || document == nil { + return false, nil, fmt.Errorf("%s notification config must be an object", channel) + } + enabled := false + enabledRaw, hasEnabled := document["enabled"] + if requireEnabled && !hasEnabled { + return false, nil, fmt.Errorf("%s.enabled is required", channel) + } + if hasEnabled { + if err := json.Unmarshal(enabledRaw, &enabled); err != nil { + return false, nil, fmt.Errorf("%s.enabled must be a boolean", channel) + } + delete(document, "enabled") + } + + fields := notificationFields[channel] + for name, value := range document { + kind, known := fields[name] + if !known { + return false, nil, fmt.Errorf("%s.%s is not supported", channel, name) + } + if err := validateNotificationField(channel, name, kind, value); err != nil { + return false, nil, err + } + } + config, err := json.Marshal(document) + if err != nil { + return false, nil, fmt.Errorf("encode %s notification config: %w", channel, err) + } + return enabled, config, nil +} + +func validateNotificationField( + channel string, + name string, + kind string, + raw json.RawMessage, +) error { + field := channel + "." + name + switch kind { + case "boolean": + var value bool + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("%s must be a boolean", field) + } + case "string": + var value string + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("%s must be a string", field) + } + limit := 4096 + if name == "text_template" { + limit = 32768 + } + if len(value) > limit || strings.ContainsAny(value, "\x00") { + return fmt.Errorf("%s is too long or contains invalid characters", field) + } + if (name == "base_url" || name == "proxy") && value != "" { + if _, err := parseOutboundURL(value, false); err != nil { + return fmt.Errorf("%s is not a valid HTTP URL", field) + } + } + if name == "from_address" && value != "" { + if _, err := mail.ParseAddress(value); err != nil { + return fmt.Errorf("%s is not a valid email address", field) + } + } + case "integer": + var value int + if err := json.Unmarshal(raw, &value); err != nil { + return fmt.Errorf("%s must be an integer", field) + } + switch name { + case "smtp_port": + if value < 0 || value > 65535 { + return fmt.Errorf("%s must be between 0 and 65535", field) + } + case "timeout_ms": + if value != 0 && (value < 100 || value > 60000) { + return fmt.Errorf("%s must be 0 or between 100 and 60000", field) + } + case "retry_max": + if value < 0 || value > 10 { + return fmt.Errorf("%s must be between 0 and 10", field) + } + } + case "strings": + var values []string + if err := json.Unmarshal(raw, &values); err != nil { + return fmt.Errorf("%s must be an array of strings", field) + } + if len(values) > 32 { + return fmt.Errorf("%s cannot contain more than 32 values", field) + } + for _, value := range values { + if strings.TrimSpace(value) == "" || len(value) > 4096 || + strings.ContainsAny(value, "\r\n\x00") { + return fmt.Errorf("%s contains an invalid value", field) + } + if name == "urls" { + if _, err := parseOutboundURL(value, false); err != nil { + return fmt.Errorf("%s contains an invalid HTTP URL", field) + } + } + if name == "to_addresses" { + if _, err := mail.ParseAddress(value); err != nil { + return fmt.Errorf("%s contains an invalid email address", field) + } + } + } + case "string_map": + var values map[string]string + if err := json.Unmarshal(raw, &values); err != nil { + return fmt.Errorf("%s must be an object of strings", field) + } + if len(values) > 32 { + return fmt.Errorf("%s cannot contain more than 32 entries", field) + } + for key, value := range values { + if strings.TrimSpace(key) == "" || len(key) > 128 || + strings.ContainsAny(key, "\r\n:\x00") { + return fmt.Errorf("%s contains an invalid header name", field) + } + if len(value) > 4096 || strings.ContainsAny(value, "\r\n\x00") { + return fmt.Errorf("%s contains an invalid header value", field) + } + } + default: + return fmt.Errorf("%s has an unsupported field type", field) + } + return nil +} + +func knownNotificationChannel(channel string) bool { + _, ok := notificationFields[channel] + return ok +} + +func (s *Server) handleNotificationTest( + w http.ResponseWriter, + r *http.Request, + channel string, +) { + if !requireMethod(w, r, http.MethodPost) { + return + } + channel = strings.ToLower(strings.TrimSpace(channel)) + if !knownNotificationChannel(channel) { + writeError(w, http.StatusNotFound, "not_found", "notification channel was not found") + return + } + if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" { + writeError( + w, + http.StatusNotImplemented, + "notification_test_unsupported", + "this notification channel does not support a connectivity test", + ) + return + } + var raw json.RawMessage + if err := s.decodeJSON(w, r, &raw); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + _, incoming, err := decodeNotificationConfig(channel, raw, false) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) + return + } + resolved, provider, err := s.resolveNotificationTestConfig( + r.Context(), + channel, + incoming, + ) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + writeError( + w, + http.StatusBadRequest, + "notification_not_configured", + "notification channel is not configured", + ) + return + } + s.writeStoreError(w, err) + return + } + if err := validateNotificationTestConfig(channel, resolved); err != nil { + writeError(w, http.StatusBadRequest, "invalid_notification_config", err.Error()) + return + } + + switch channel { + case "webhook": + err = sendWebhookNotificationTest(r.Context(), resolved) + case "telegram": + err = sendTelegramNotificationTest(r.Context(), resolved) + case "email": + err = sendEmailNotificationTest(r.Context(), resolved) + case "bark": + err = sendBarkNotificationTest(r.Context(), resolved) + } + if err != nil { + redacted := store.RedactText(err.Error(), provider) + if s.logger != nil { + s.logger.Warn( + "notification connectivity test failed", + "channel", + channel, + "error", + redacted, + ) + } + switch { + case errors.Is(err, errUnsafeDestination): + writeError( + w, + http.StatusBadRequest, + "unsafe_destination", + "notification destination must resolve only to public network addresses", + ) + case errors.Is(err, errProviderRejected): + writeError( + w, + http.StatusBadGateway, + "notification_provider_rejected", + "notification provider rejected the test message", + ) + default: + writeError( + w, + http.StatusBadGateway, + "notification_test_failed", + "notification provider could not be reached or the test message failed", + ) + } + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "channel": channel, + "success": true, + "tested_at": time.Now().UTC(), + }, + }) +} + +func (s *Server) resolveNotificationTestConfig( + ctx context.Context, + channel string, + incoming json.RawMessage, +) (map[string]any, store.NotificationSetting, error) { + current, err := s.store.NotificationSetting(ctx, channel) + notConfigured := errors.Is(err, store.ErrNotFound) + if err != nil && !errors.Is(err, store.ErrNotFound) { + return nil, store.NotificationSetting{}, err + } + if notConfigured { + current = store.NotificationSetting{ + Channel: channel, + Config: json.RawMessage(`{}`), + SensitiveFields: store.DefaultNotificationSensitiveFields(channel), + } + } + var resolved map[string]any + if err := json.Unmarshal(current.Config, &resolved); err != nil { + return nil, store.NotificationSetting{}, fmt.Errorf("decode stored notification config: %w", err) + } + var overlay map[string]any + if err := json.Unmarshal(incoming, &overlay); err != nil { + return nil, store.NotificationSetting{}, fmt.Errorf("decode notification test config: %w", err) + } + sensitive := make(map[string]struct{}) + for _, field := range store.DefaultNotificationSensitiveFields(channel) { + sensitive[field] = struct{}{} + } + for key, value := range overlay { + if _, secret := sensitive[key]; secret { + if text, ok := value.(string); !ok || text == "" || text == store.SecretMask { + continue + } + } + resolved[key] = value + } + encoded, err := json.Marshal(resolved) + if err != nil { + return nil, store.NotificationSetting{}, err + } + if len(resolved) == 0 && notConfigured { + return nil, store.NotificationSetting{}, store.ErrNotFound + } + _, normalized, err := decodeNotificationConfig(channel, encoded, false) + if err != nil { + return nil, store.NotificationSetting{}, err + } + if err := json.Unmarshal(normalized, &resolved); err != nil { + return nil, store.NotificationSetting{}, err + } + provider := store.NotificationSetting{ + Channel: channel, + Config: normalized, + SensitiveFields: store.DefaultNotificationSensitiveFields(channel), + } + return resolved, provider, nil +} + +func validateNotificationTestConfig(channel string, config map[string]any) error { + switch channel { + case "webhook": + urls := configStrings(config, "urls") + if len(urls) == 0 { + return errors.New("webhook.urls must contain at least one URL") + } + if len(urls) > 8 { + return errors.New("webhook test is limited to 8 URLs") + } + case "bark": + urls := configStrings(config, "urls") + if len(urls) == 0 { + return errors.New("bark.urls must contain at least one URL") + } + if len(urls) > 8 { + return errors.New("bark test is limited to 8 URLs") + } + case "telegram": + token := configString(config, "bot_token") + if token == "" || token == store.SecretMask { + return errors.New("telegram.bot_token is required") + } + if !telegramTokenPattern.MatchString(token) { + return errors.New("telegram.bot_token has an invalid format") + } + if configString(config, "chat_id") == "" { + return errors.New("telegram.chat_id is required") + } + if baseURL := configString(config, "base_url"); baseURL != "" { + if _, err := parseOutboundURL(baseURL, true); err != nil { + return errors.New("telegram.base_url must be an absolute HTTPS URL") + } + } + case "email": + if configString(config, "smtp_host") == "" { + return errors.New("email.smtp_host is required") + } + if configString(config, "from_address") == "" { + return errors.New("email.from_address is required") + } + if len(configStrings(config, "to_addresses")) == 0 { + return errors.New("email.to_addresses must contain at least one address") + } + if configString(config, "password") != "" && configString(config, "username") == "" { + return errors.New("email.username is required when a password is configured") + } + } + return nil +} + +func sendWebhookNotificationTest(ctx context.Context, config map[string]any) error { + timeout := durationMilliseconds(configInt(config, "timeout_ms"), 5*time.Second) + client, err := restrictedHTTPClient(ctx, timeout, "") + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{ + "event": "test", + "message": "vocat notification test", + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + parsed.String(), + bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create webhook test request: %w", err) + } + for name, value := range configStringMap(config, "headers") { + request.Header.Set(name, value) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-notification-test/1") + if secret := configString(config, "secret"); secret != "" { + signature := hmac.New(sha256.New, []byte(secret)) + _, _ = signature.Write(payload) + request.Header.Set( + "X-vocat-Signature", + "sha256="+hex.EncodeToString(signature.Sum(nil)), + ) + } + if err := performNotificationRequest(client, request, false); err != nil { + return err + } + } + return nil +} + +func sendBarkNotificationTest(ctx context.Context, config map[string]any) error { + client, err := restrictedHTTPClient(ctx, 6*time.Second, "") + if err != nil { + return err + } + message := map[string]any{ + "title": "vocat", + "body": "vocat notification test", + } + if group := configString(config, "group"); group != "" { + message["group"] = group + } + if icon := configString(config, "icon"); icon != "" { + message["icon"] = icon + } + if level := configString(config, "level"); level != "" { + message["level"] = level + } + payload, _ := json.Marshal(message) + for _, destination := range configStrings(config, "urls") { + parsed, err := validateOutboundURL(ctx, destination, false) + if err != nil { + return err + } + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + parsed.String(), + bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create bark test request: %w", err) + } + request.Header.Set("Content-Type", "application/json; charset=utf-8") + request.Header.Set("User-Agent", "vocat-notification-test/1") + if err := performNotificationRequest(client, request, false); err != nil { + return err + } + } + return nil +} + +func sendTelegramNotificationTest(ctx context.Context, config map[string]any) error { + baseURL := configString(config, "base_url") + if baseURL == "" { + baseURL = "https://api.telegram.org" + } + parsed, err := validateOutboundURL(ctx, baseURL, true) + if err != nil { + return err + } + token := configString(config, "bot_token") + parsed.Path = strings.TrimRight(parsed.Path, "/") + "/bot" + token + "/sendMessage" + parsed.RawPath = "" + parsed.RawQuery = "" + parsed.Fragment = "" + client, err := restrictedHTTPClient(ctx, 6*time.Second, configString(config, "proxy")) + if err != nil { + return err + } + payload, _ := json.Marshal(map[string]any{ + "chat_id": configString(config, "chat_id"), + "text": "vocat notification test", + }) + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + parsed.String(), + bytes.NewReader(payload), + ) + if err != nil { + return fmt.Errorf("create Telegram test request: %w", err) + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("User-Agent", "vocat-notification-test/1") + return performNotificationRequest(client, request, true) +} + +func performNotificationRequest( + client *http.Client, + request *http.Request, + requireTelegramOK bool, +) error { + response, err := client.Do(request) + if err != nil { + return fmt.Errorf("send notification test: %w", err) + } + defer response.Body.Close() + body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)) + if readErr != nil { + return fmt.Errorf("read notification response: %w", readErr) + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("%w: HTTP %d", errProviderRejected, response.StatusCode) + } + if requireTelegramOK { + var result struct { + OK bool `json:"ok"` + } + if json.Unmarshal(body, &result) != nil || !result.OK { + return fmt.Errorf("%w: Telegram response was not successful", errProviderRejected) + } + } + return nil +} + +func sendEmailNotificationTest(ctx context.Context, config map[string]any) error { + host := strings.TrimSpace(configString(config, "smtp_host")) + port := configInt(config, "smtp_port") + if port == 0 { + port = 587 + } + timeout := 8 * time.Second + address := net.JoinHostPort(host, strconv.Itoa(port)) + connection, err := dialRestricted(ctx, "tcp", address, timeout) + if err != nil { + return fmt.Errorf("connect SMTP server: %w", err) + } + defer connection.Close() + if err := connection.SetDeadline(time.Now().Add(timeout)); err != nil { + return fmt.Errorf("set SMTP deadline: %w", err) + } + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + ServerName: host, + } + if port == 465 { + secure := tls.Client(connection, tlsConfig) + if err := secure.HandshakeContext(ctx); err != nil { + return fmt.Errorf("establish SMTP TLS: %w", err) + } + connection = secure + } + client, err := smtp.NewClient(connection, host) + if err != nil { + return fmt.Errorf("start SMTP session: %w", err) + } + defer client.Close() + if port != 465 { + if available, _ := client.Extension("STARTTLS"); !available { + return errors.New("SMTP server does not offer STARTTLS") + } + if err := client.StartTLS(tlsConfig); err != nil { + return fmt.Errorf("start SMTP TLS: %w", err) + } + } + username := configString(config, "username") + password := configString(config, "password") + if username != "" { + if err := client.Auth(smtp.PlainAuth("", username, password, host)); err != nil { + return fmt.Errorf("%w: SMTP authentication failed", errProviderRejected) + } + } + from, err := mail.ParseAddress(configString(config, "from_address")) + if err != nil { + return fmt.Errorf("parse sender address: %w", err) + } + recipients := make([]*mail.Address, 0) + for _, item := range configStrings(config, "to_addresses") { + address, err := mail.ParseAddress(item) + if err != nil { + return fmt.Errorf("parse recipient address: %w", err) + } + recipients = append(recipients, address) + } + if err := client.Mail(from.Address); err != nil { + return fmt.Errorf("%w: SMTP sender rejected", errProviderRejected) + } + for _, recipient := range recipients { + if err := client.Rcpt(recipient.Address); err != nil { + return fmt.Errorf("%w: SMTP recipient rejected", errProviderRejected) + } + } + writer, err := client.Data() + if err != nil { + return fmt.Errorf("%w: SMTP message rejected", errProviderRejected) + } + message := strings.Join([]string{ + "Date: " + time.Now().UTC().Format(time.RFC1123Z), + "From: " + from.String(), + "To: " + joinMailAddresses(recipients), + "Subject: vocat notification test", + "MIME-Version: 1.0", + "Content-Type: text/plain; charset=UTF-8", + "", + "This is a vocat notification test.", + "", + }, "\r\n") + if _, err := io.WriteString(writer, message); err != nil { + _ = writer.Close() + return fmt.Errorf("write SMTP test message: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("%w: SMTP message not accepted", errProviderRejected) + } + if err := client.Quit(); err != nil { + return fmt.Errorf("finish SMTP session: %w", err) + } + return nil +} + +func joinMailAddresses(values []*mail.Address) string { + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, value.String()) + } + return strings.Join(result, ", ") +} + +func restrictedHTTPClient( + ctx context.Context, + timeout time.Duration, + proxy string, +) (*http.Client, error) { + timeout = clampNotificationTimeout(timeout) + transport := &http.Transport{ + Proxy: nil, + DialContext: restrictedDialer(timeout), + ForceAttemptHTTP2: true, + DisableKeepAlives: true, + MaxIdleConns: 0, + TLSHandshakeTimeout: timeout, + ResponseHeaderTimeout: timeout, + ExpectContinueTimeout: time.Second, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + }, + } + if strings.TrimSpace(proxy) != "" { + parsed, err := validateOutboundURL(ctx, proxy, false) + if err != nil { + return nil, fmt.Errorf("validate notification proxy: %w", err) + } + transport.Proxy = http.ProxyURL(parsed) + } + return &http.Client{ + Transport: transport, + Timeout: timeout, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return errors.New("notification provider redirects are not allowed") + }, + }, nil +} + +func clampNotificationTimeout(timeout time.Duration) time.Duration { + if timeout < 100*time.Millisecond { + return 100 * time.Millisecond + } + if timeout > 10*time.Second { + return 10 * time.Second + } + return timeout +} + +func durationMilliseconds(value int, fallback time.Duration) time.Duration { + if value == 0 { + return fallback + } + return time.Duration(value) * time.Millisecond +} + +func validateOutboundURL( + ctx context.Context, + raw string, + requireHTTPS bool, +) (*url.URL, error) { + parsed, err := parseOutboundURL(raw, requireHTTPS) + if err != nil { + return nil, err + } + if _, err := resolvePublicAddresses(ctx, parsed.Hostname()); err != nil { + return nil, err + } + return parsed, nil +} + +func parseOutboundURL(raw string, requireHTTPS bool) (*url.URL, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Hostname() == "" || parsed.IsAbs() == false { + return nil, errors.New("destination must be an absolute HTTP URL") + } + if parsed.User != nil { + return nil, errors.New("destination URL cannot contain user information") + } + if parsed.Scheme != "http" && parsed.Scheme != "https" { + return nil, errors.New("destination URL must use HTTP or HTTPS") + } + if requireHTTPS && parsed.Scheme != "https" { + return nil, errors.New("destination URL must use HTTPS") + } + if parsed.Port() != "" { + port, err := strconv.Atoi(parsed.Port()) + if err != nil || port < 1 || port > 65535 { + return nil, errors.New("destination URL has an invalid port") + } + } + return parsed, nil +} + +func restrictedDialer(timeout time.Duration) func( + context.Context, + string, + string, +) (net.Conn, error) { + return func(ctx context.Context, network string, address string) (net.Conn, error) { + return dialRestricted(ctx, network, address, timeout) + } +} + +func dialRestricted( + ctx context.Context, + network string, + address string, + timeout time.Duration, +) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, fmt.Errorf("parse outbound address: %w", err) + } + addresses, err := resolvePublicAddresses(ctx, host) + if err != nil { + return nil, err + } + dialer := net.Dialer{Timeout: clampNotificationTimeout(timeout)} + var failures []error + for _, ip := range addresses { + connection, err := dialer.DialContext( + ctx, + network, + net.JoinHostPort(ip.String(), port), + ) + if err == nil { + return connection, nil + } + failures = append(failures, err) + } + return nil, fmt.Errorf("dial public notification destination: %w", errors.Join(failures...)) +} + +func resolvePublicAddresses(ctx context.Context, host string) ([]netip.Addr, error) { + normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) + if normalized == "" || normalized == "localhost" || + strings.HasSuffix(normalized, ".localhost") || + normalized == "metadata" || + strings.HasSuffix(normalized, ".internal") || + strings.HasSuffix(normalized, ".local") { + return nil, fmt.Errorf("%w: blocked host name", errUnsafeDestination) + } + if literal, err := netip.ParseAddr(normalized); err == nil { + literal = literal.Unmap() + if !publicNotificationAddress(literal) { + return nil, fmt.Errorf("%w: %s", errUnsafeDestination, literal) + } + return []netip.Addr{literal}, nil + } + addresses, err := net.DefaultResolver.LookupNetIP(ctx, "ip", normalized) + if err != nil { + return nil, fmt.Errorf("resolve notification destination: %w", err) + } + if len(addresses) == 0 { + return nil, errors.New("notification destination did not resolve") + } + result := make([]netip.Addr, 0, len(addresses)) + for _, address := range addresses { + address = address.Unmap() + if !publicNotificationAddress(address) { + return nil, fmt.Errorf("%w: %s", errUnsafeDestination, address) + } + result = append(result, address) + } + return result, nil +} + +var blockedNotificationNetworks = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("224.0.0.0/4"), + netip.MustParsePrefix("240.0.0.0/4"), + netip.MustParsePrefix("::/128"), + netip.MustParsePrefix("::1/128"), + netip.MustParsePrefix("64:ff9b:1::/48"), + netip.MustParsePrefix("100::/64"), + netip.MustParsePrefix("2001:db8::/32"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("ff00::/8"), +} + +func publicNotificationAddress(address netip.Addr) bool { + if !address.IsValid() || !address.IsGlobalUnicast() { + return false + } + address = address.Unmap() + for _, blocked := range blockedNotificationNetworks { + if blocked.Contains(address) { + return false + } + } + return true +} + +func configString(config map[string]any, key string) string { + value, _ := config[key].(string) + return strings.TrimSpace(value) +} + +func configStrings(config map[string]any, key string) []string { + switch value := config[key].(type) { + case []string: + return value + case []any: + result := make([]string, 0, len(value)) + for _, item := range value { + text, ok := item.(string) + if ok { + result = append(result, strings.TrimSpace(text)) + } + } + return result + default: + return nil + } +} + +func configStringMap(config map[string]any, key string) map[string]string { + object, ok := config[key].(map[string]any) + if !ok { + return nil + } + result := make(map[string]string, len(object)) + for name, value := range object { + text, ok := value.(string) + if ok { + result[name] = text + } + } + return result +} + +func configInt(config map[string]any, key string) int { + switch value := config[key].(type) { + case float64: + return int(value) + case json.Number: + result, _ := value.Int64() + return int(result) + case int: + return value + default: + return 0 + } +} + +// handleCardPolicies returns every stored card policy (VoHive: GET /cards/policies). +func (s *Server) handleCardPolicies(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + policies, err := s.store.ListCardPolicies(r.Context()) + if err != nil { + s.writeStoreError(w, err) + return + } + result := make([]map[string]any, 0, len(policies)) + for _, policy := range policies { + result = append(result, cardPolicyResponse(policy)) + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +// liveCardPolicyFlags resolves the current VoWiFi/airplane state for the +// device that presently hosts the given SIM (matched by live ICCID), so the card +// policy toggles reflect what the card is actually doing now rather than a stale +// stored value. ok is false when no present device reports this ICCID. +func (s *Server) liveCardPolicyFlags(ctx context.Context, iccid string) (vowifi, airplane, ok bool) { + configs, err := s.store.ListDevices(ctx) + if err != nil { + return false, false, false + } + clean := strings.TrimSpace(iccid) + for _, config := range configs { + entry, _, present := s.physicalForConfig(config) + if !present || entry.Snapshot == nil { + continue + } + if !strings.EqualFold(strings.TrimSpace(entry.Snapshot.ICCID), clean) { + continue + } + // VoWiFi deliberately puts the modem into RF-off mode while the SWu/IMS + // path owns service. That physical CFUN state is not the user's separate + // airplane-mode policy; exposing both toggles as enabled is contradictory + // and makes the UI unable to represent the active policy correctly. + return config.VoWiFiEnabled, entry.Snapshot.FlightMode && !config.VoWiFiEnabled, true + } + return false, false, false +} + +func (s *Server) handleCardPolicy(w http.ResponseWriter, r *http.Request, iccid string) { + iccid = strings.TrimSpace(iccid) + if !validICCID(iccid) { + writeError( + w, + http.StatusBadRequest, + "invalid_iccid", + "ICCID must contain between 10 and 32 decimal digits", + ) + return + } + switch r.Method { + case http.MethodGet: + policy, err := s.store.CardPolicy(r.Context(), iccid) + if errors.Is(err, store.ErrNotFound) { + policy = store.CardPolicy{ + ICCID: iccid, + IPVersion: "IPV4V6", + Source: "default", + } + } else if err != nil { + s.writeStoreError(w, err) + return + } + // Reflect the SIM's live current state in the toggles (APN / IP version + // remain stored preferences); fall back to the stored policy when the card + // is not currently present in any device. + if vowifi, airplane, ok := s.liveCardPolicyFlags(r.Context(), iccid); ok { + policy.VoWiFiEnabled = vowifi + policy.AirplaneEnabled = airplane + } + writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)}) + case http.MethodPut: + var request struct { + VoWiFiEnabled *bool `json:"vowifi_enabled"` + AirplaneEnabled *bool `json:"airplane_enabled"` + APN string `json:"apn"` + IPVersion string `json:"ip_version"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if request.VoWiFiEnabled == nil || + request.AirplaneEnabled == nil { + writeError( + w, + http.StatusBadRequest, + "invalid_card_policy", + "all card policy switches are required", + ) + return + } + request.APN = strings.TrimSpace(request.APN) + if len(request.APN) > 128 || strings.ContainsAny(request.APN, "\r\n\x00") { + writeError(w, http.StatusBadRequest, "invalid_card_policy", "APN is invalid") + return + } + request.IPVersion = strings.ToUpper(strings.TrimSpace(request.IPVersion)) + if request.IPVersion == "" { + request.IPVersion = "IPV4V6" + } + if request.IPVersion != "IP" && + request.IPVersion != "IPV6" && + request.IPVersion != "IPV4V6" { + writeError( + w, + http.StatusBadRequest, + "invalid_card_policy", + "IP version must be IP, IPV6, or IPV4V6", + ) + return + } + if *request.VoWiFiEnabled && *request.AirplaneEnabled { + writeError( + w, + http.StatusBadRequest, + "invalid_card_policy", + "VoWiFi and airplane mode cannot both be enabled", + ) + return + } + policy := store.CardPolicy{ + ICCID: iccid, + NetworkEnabled: false, + VoWiFiEnabled: *request.VoWiFiEnabled, + AirplaneEnabled: *request.AirplaneEnabled, + APN: request.APN, + IPVersion: request.IPVersion, + Source: "manual", + } + if err := s.store.UpsertCardPolicy(r.Context(), policy); err != nil { + s.writeStoreError(w, err) + return + } + policy, err := s.store.CardPolicy(r.Context(), iccid) + if err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": cardPolicyResponse(policy)}) + default: + w.Header().Set("Allow", "GET, PUT") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func validICCID(value string) bool { + if len(value) < 10 || len(value) > 32 { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} + +func cardPolicyResponse(policy store.CardPolicy) map[string]any { + response := map[string]any{ + "iccid": policy.ICCID, + "network_enabled": false, + "vowifi_enabled": policy.VoWiFiEnabled, + "airplane_enabled": policy.AirplaneEnabled, + "apn": policy.APN, + "ip_version": policy.IPVersion, + "source": policy.Source, + } + if !policy.CreatedAt.IsZero() { + response["created_at"] = policy.CreatedAt + } + if !policy.UpdatedAt.IsZero() { + response["updated_at"] = policy.UpdatedAt + } + return response +} + +func (s *Server) handleTrafficAnalysis(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + rangeName := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("range"))) + if rangeName == "" { + rangeName = "day" + } + var window time.Duration + switch rangeName { + case "hour": + window = time.Hour + case "day": + window = 24 * time.Hour + case "week": + window = 7 * 24 * time.Hour + case "month": + window = 30 * 24 * time.Hour + default: + writeError( + w, + http.StatusBadRequest, + "invalid_range", + "traffic range must be hour, day, week, or month", + ) + return + } + deviceID := strings.TrimSpace(r.URL.Query().Get("device_id")) + if len(deviceID) > 128 || strings.ContainsAny(deviceID, "\x00\r\n") { + writeError(w, http.StatusBadRequest, "invalid_device", "device ID is invalid") + return + } + now := time.Now().UTC() + rows, err := s.store.ListTrafficBuckets(r.Context(), store.TrafficFilter{ + DeviceID: deviceID, + Bucket: rangeName, + Since: now.Add(-window), + Until: now.Add(time.Minute), + Limit: 1000, + }) + if err != nil { + s.writeStoreError(w, err) + return + } + type aggregate struct { + period time.Time + rx int64 + tx int64 + } + byPeriod := make(map[int64]*aggregate) + for _, row := range rows { + key := row.PeriodStart.Unix() + value := byPeriod[key] + if value == nil { + value = &aggregate{period: row.PeriodStart} + byPeriod[key] = value + } + value.rx += row.RXBytes + value.tx += row.TXBytes + } + values := make([]*aggregate, 0, len(byPeriod)) + for _, value := range byPeriod { + values = append(values, value) + } + sort.Slice(values, func(left, right int) bool { + return values[left].period.Before(values[right].period) + }) + buckets := make([]map[string]any, 0, len(values)) + for _, value := range values { + buckets = append(buckets, map[string]any{ + "bucket": rangeName, + "period_start": value.period, + "rx_bytes": value.rx, + "tx_bytes": value.tx, + "total_bytes": value.rx + value.tx, + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{ + "status": "ok", + "range": rangeName, + "buckets": buckets, + }, + }) +} diff --git a/internal/server/settings_api_test.go b/internal/server/settings_api_test.go new file mode 100644 index 0000000..4826054 --- /dev/null +++ b/internal/server/settings_api_test.go @@ -0,0 +1,530 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/netip" + "strings" + "sync/atomic" + "testing" + "time" + + "vocat/internal/store" +) + +type settingsAPITest struct { + server *Server + database *store.Store +} + +func newSettingsAPITest(t *testing.T) settingsAPITest { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Errorf("close database: %v", err) + } + }) + return settingsAPITest{ + server: &Server{ + store: database, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + maxRequestBodyBytes: 1 << 20, + }, + database: database, + } +} + +func (test settingsAPITest) request( + t *testing.T, + method string, + target string, + body string, +) *httptest.ResponseRecorder { + t.Helper() + request := httptest.NewRequest(method, target, strings.NewReader(body)) + if body != "" { + request.Header.Set("Content-Type", "application/json") + } + recorder := httptest.NewRecorder() + cleanPath := strings.Trim(strings.TrimPrefix(request.URL.Path, "/api"), "/") + if !test.server.routeSettingsAPI(recorder, request, cleanPath) { + writeError(recorder, http.StatusNotFound, "not_found", "API endpoint not found") + } + return recorder +} + +func decodeSettingsResponse(t *testing.T, recorder *httptest.ResponseRecorder) map[string]any { + t.Helper() + var response map[string]any + if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response %q: %v", recorder.Body.String(), err) + } + return response +} + +func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *testing.T) { + test := newSettingsAPITest(t) + recorder := test.request(t, http.MethodGet, "/api/settings/notifications", "") + if recorder.Code != http.StatusOK { + t.Fatalf("GET status = %d, body = %s", recorder.Code, recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + data, ok := response["data"].(map[string]any) + if !ok || len(data) != len(notificationChannels) { + t.Fatalf("notification channels = %#v", response["data"]) + } + for _, channel := range notificationChannels { + config, ok := data[channel].(map[string]any) + if !ok || config["enabled"] != false { + t.Fatalf("missing disabled channel %q: %#v", channel, config) + } + } + + if err := test.database.UpsertNotificationSetting( + context.Background(), + store.NotificationSetting{ + Channel: "telegram", + Enabled: true, + Config: json.RawMessage( + `{"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1"}`, + ), + }, + ); err != nil { + t.Fatal(err) + } + recorder = test.request( + t, + http.MethodPut, + "/api/settings/notifications", + `{"telegram":{"enabled":true,"bot_token":"********","chat_id":"2"}}`, + ) + if recorder.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + if bytes.Contains(recorder.Body.Bytes(), []byte("abcdefghijklmnopqrstuvwxyz")) { + t.Fatalf("PUT response leaked secret: %s", recorder.Body) + } + response = decodeSettingsResponse(t, recorder) + data = response["data"].(map[string]any) + telegram := data["telegram"].(map[string]any) + if telegram["bot_token"] != store.SecretMask || telegram["chat_id"] != "2" { + t.Fatalf("redacted Telegram config = %#v", telegram) + } + stored, err := test.database.NotificationSetting(context.Background(), "telegram") + if err != nil { + t.Fatal(err) + } + var storedConfig map[string]any + if err := json.Unmarshal(stored.Config, &storedConfig); err != nil { + t.Fatal(err) + } + if storedConfig["bot_token"] != "123456:abcdefghijklmnopqrstuvwxyz" || + storedConfig["chat_id"] != "2" { + t.Fatalf("stored Telegram config = %#v", storedConfig) + } +} + +func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) { + test := newSettingsAPITest(t) + cases := []struct { + name string + body string + code string + }{ + { + name: "unknown channel", + body: `{"pagerduty":{"enabled":true}}`, + code: "invalid_notification_channel", + }, + { + name: "missing enabled", + body: `{"telegram":{"chat_id":"1"}}`, + code: "invalid_notification_config", + }, + { + name: "wrong field type", + body: `{"webhook":{"enabled":true,"urls":"https://example.com"}}`, + code: "invalid_notification_config", + }, + { + name: "unknown field", + body: `{"email":{"enabled":false,"smtp_host":"mail.example.com","typo":1}}`, + code: "invalid_notification_config", + }, + { + name: "header value with newline", + body: `{"webhook":{"enabled":true,"headers":{"X-Api-Key":"a\nb"}}}`, + code: "invalid_notification_config", + }, + { + name: "header name with colon", + body: `{"webhook":{"enabled":true,"headers":{"X:Bad":"v"}}}`, + code: "invalid_notification_config", + }, + { + name: "null body", + body: `null`, + code: "invalid_request", + }, + } + for _, item := range cases { + t.Run(item.name, func(t *testing.T) { + recorder := test.request( + t, + http.MethodPut, + "/api/settings/notifications", + item.body, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + detail := response["error"].(map[string]any) + if detail["code"] != item.code { + t.Fatalf("error = %#v", detail) + } + }) + } +} + +func TestNotificationTestsBlockSSRFAndUnsupportedChannels(t *testing.T) { + test := newSettingsAPITest(t) + var webhookHits atomic.Int32 + local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + webhookHits.Add(1) + w.WriteHeader(http.StatusNoContent) + })) + defer local.Close() + + recorder := test.request( + t, + http.MethodPost, + "/api/settings/notifications/webhook/test", + `{"urls":[`+strconvJSON(local.URL)+`]}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("webhook SSRF status = %d, body = %s", recorder.Code, recorder.Body) + } + if webhookHits.Load() != 0 { + t.Fatalf("blocked webhook reached local service %d times", webhookHits.Load()) + } + response := decodeSettingsResponse(t, recorder) + if response["error"].(map[string]any)["code"] != "unsafe_destination" { + t.Fatalf("webhook SSRF response = %#v", response) + } + + recorder = test.request( + t, + http.MethodPost, + "/api/settings/notifications/telegram/test", + `{"bot_token":"123456:abcdefghijklmnopqrstuvwxyz","chat_id":"1","base_url":"https://169.254.169.254"}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("Telegram metadata status = %d, body = %s", recorder.Code, recorder.Body) + } + + recorder = test.request( + t, + http.MethodPost, + "/api/settings/notifications/email/test", + `{"smtp_host":"127.0.0.1","smtp_port":25,"from_address":"from@example.com","to_addresses":["to@example.com"]}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("SMTP SSRF status = %d, body = %s", recorder.Code, recorder.Body) + } + + recorder = test.request( + t, + http.MethodPost, + "/api/settings/notifications/bark/test", + `{"urls":[`+strconvJSON(local.URL)+`]}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("bark SSRF status = %d, body = %s", recorder.Code, recorder.Body) + } + response = decodeSettingsResponse(t, recorder) + if response["error"].(map[string]any)["code"] != "unsafe_destination" { + t.Fatalf("bark SSRF response = %#v", response) + } + + recorder = test.request( + t, + http.MethodPost, + "/api/settings/notifications/bark/test", + `{}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("bark empty status = %d", recorder.Code) + } + response = decodeSettingsResponse(t, recorder) + if response["error"].(map[string]any)["code"] != "notification_not_configured" { + t.Fatalf("bark empty response = %#v", response) + } + + // pushplus is a supported channel but has no connectivity test. + recorder = test.request( + t, + http.MethodPost, + "/api/settings/notifications/pushplus/test", + `{}`, + ) + if recorder.Code != http.StatusNotImplemented { + t.Fatalf("unsupported notification status = %d", recorder.Code) + } + response = decodeSettingsResponse(t, recorder) + if response["error"].(map[string]any)["code"] != "notification_test_unsupported" { + t.Fatalf("unsupported response = %#v", response) + } + + // Removed channels (feishu, qq, weixin) are no longer recognised at all. + for _, removed := range []string{"feishu", "qq", "weixin"} { + recorder = test.request( + t, + http.MethodPost, + "/api/settings/notifications/"+removed+"/test", + `{}`, + ) + if recorder.Code != http.StatusNotFound { + t.Fatalf("removed channel %q status = %d", removed, recorder.Code) + } + } +} + +func strconvJSON(value string) string { + encoded, _ := json.Marshal(value) + return string(encoded) +} + +func TestNotificationWebhookHeadersRoundTrip(t *testing.T) { + test := newSettingsAPITest(t) + recorder := test.request( + t, + http.MethodPut, + "/api/settings/notifications", + `{"webhook":{"enabled":true,"urls":["https://example.com/hook"],`+ + `"timeout_ms":30000,"retry_max":2,"headers":{"X-Api-Key":"abc"}}}`, + ) + if recorder.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + stored, err := test.database.NotificationSetting(context.Background(), "webhook") + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(stored.Config, &config); err != nil { + t.Fatal(err) + } + headers, ok := config["headers"].(map[string]any) + if !ok || headers["X-Api-Key"] != "abc" { + t.Fatalf("stored webhook headers = %#v", config) + } + if config["timeout_ms"] != float64(30000) { + t.Fatalf("stored webhook timeout = %#v", config["timeout_ms"]) + } +} + +func TestNotificationEmailUseSslRoundTrip(t *testing.T) { + test := newSettingsAPITest(t) + recorder := test.request( + t, + http.MethodPut, + "/api/settings/notifications", + `{"email":{"enabled":true,"use_ssl":true,"smtp_host":"smtp.example.com","smtp_port":465,`+ + `"username":"u@example.com","password":"mail_secret","from_address":"u@example.com",`+ + `"to_addresses":["a@example.com"]}}`, + ) + if recorder.Code != http.StatusOK { + t.Fatalf("PUT status = %d, body = %s", recorder.Code, recorder.Body) + } + stored, err := test.database.NotificationSetting(context.Background(), "email") + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(stored.Config, &config); err != nil { + t.Fatal(err) + } + if config["use_ssl"] != true || config["smtp_port"] != float64(465) { + t.Fatalf("stored email config = %#v", config) + } + + recorder = test.request( + t, + http.MethodPut, + "/api/settings/notifications", + `{"email":{"enabled":true,"use_ssl":"yes"}}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("wrong-type use_ssl status = %d, body = %s", recorder.Code, recorder.Body) + } +} + +func TestCardPolicyDefaultValidationAndPersistence(t *testing.T) { + test := newSettingsAPITest(t) + const iccid = "89860012345678901234" + recorder := test.request( + t, + http.MethodGet, + "/api/cards/"+iccid+"/policy", + "", + ) + if recorder.Code != http.StatusOK { + t.Fatalf("default policy status = %d, body = %s", recorder.Code, recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + policy := response["data"].(map[string]any) + if policy["iccid"] != iccid || policy["source"] != "default" || + policy["ip_version"] != "IPV4V6" { + t.Fatalf("default policy = %#v", policy) + } + + recorder = test.request( + t, + http.MethodPut, + "/api/cards/"+iccid+"/policy", + `{"vowifi_enabled":true,"airplane_enabled":true,"apn":"ims","ip_version":"IPV4V6"}`, + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("conflicting policy status = %d, body = %s", recorder.Code, recorder.Body) + } + + recorder = test.request( + t, + http.MethodPut, + "/api/cards/"+iccid+"/policy", + `{"vowifi_enabled":true,"airplane_enabled":false,"apn":"ims","ip_version":"ipv4v6"}`, + ) + if recorder.Code != http.StatusOK { + t.Fatalf("save policy status = %d, body = %s", recorder.Code, recorder.Body) + } + response = decodeSettingsResponse(t, recorder) + policy = response["data"].(map[string]any) + if policy["source"] != "manual" || policy["vowifi_enabled"] != true || + policy["ip_version"] != "IPV4V6" { + t.Fatalf("saved policy = %#v", policy) + } + stored, err := test.database.CardPolicy(context.Background(), iccid) + if err != nil || !stored.VoWiFiEnabled || stored.APN != "ims" { + t.Fatalf("stored policy = %+v, %v", stored, err) + } + + recorder = test.request(t, http.MethodGet, "/api/cards/not-an-iccid/policy", "") + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid ICCID status = %d", recorder.Code) + } +} + +func TestTrafficAnalysisUsesAndAggregatesStoredBuckets(t *testing.T) { + test := newSettingsAPITest(t) + period := time.Now().UTC().Add(-time.Hour).Truncate(time.Minute) + for _, bucket := range []store.TrafficBucket{ + { + DeviceID: "ec20-1", Bucket: "day", PeriodStart: period, + RXBytes: 100, TXBytes: 20, + }, + { + DeviceID: "ec20-2", Bucket: "day", PeriodStart: period, + RXBytes: 50, TXBytes: 30, + }, + { + DeviceID: "ec20-1", Bucket: "week", PeriodStart: period, + RXBytes: 9999, TXBytes: 9999, + }, + } { + if err := test.database.UpsertTrafficBucket(context.Background(), bucket); err != nil { + t.Fatal(err) + } + } + recorder := test.request( + t, + http.MethodGet, + "/api/traffic/analysis?range=day", + "", + ) + if recorder.Code != http.StatusOK { + t.Fatalf("traffic status = %d, body = %s", recorder.Code, recorder.Body) + } + response := decodeSettingsResponse(t, recorder) + data := response["data"].(map[string]any) + buckets := data["buckets"].([]any) + if len(buckets) != 1 { + t.Fatalf("traffic buckets = %#v", buckets) + } + bucket := buckets[0].(map[string]any) + if bucket["rx_bytes"] != float64(150) || + bucket["tx_bytes"] != float64(50) || + bucket["total_bytes"] != float64(200) { + t.Fatalf("aggregated bucket = %#v", bucket) + } + + recorder = test.request( + t, + http.MethodGet, + "/api/traffic/analysis?range=year", + "", + ) + if recorder.Code != http.StatusBadRequest { + t.Fatalf("invalid traffic range status = %d", recorder.Code) + } +} + +func TestNotificationDestinationAddressPolicy(t *testing.T) { + blocked := []string{ + "0.0.0.0", "10.0.0.1", "100.100.100.200", "127.0.0.1", + "169.254.169.254", "172.16.0.1", "192.168.1.1", "198.18.0.1", + "::1", "fc00::1", "fe80::1", "2001:db8::1", + } + for _, text := range blocked { + address := netip.MustParseAddr(text) + if publicNotificationAddress(address) { + t.Errorf("%s was incorrectly accepted as public", text) + } + } + for _, text := range []string{"1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"} { + address := netip.MustParseAddr(text) + if !publicNotificationAddress(address) { + t.Errorf("%s was incorrectly blocked", text) + } + } + if _, err := resolvePublicAddresses(context.Background(), "localhost"); err == nil { + t.Fatal("localhost was not blocked") + } + if _, err := resolvePublicAddresses( + context.Background(), + "169.254.169.254", + ); err == nil { + t.Fatal("metadata IP was not blocked") + } +} + +func TestRestrictedNotificationClientCapsTimeoutAndRedirects(t *testing.T) { + client, err := restrictedHTTPClient(context.Background(), time.Minute, "") + if err != nil { + t.Fatal(err) + } + if client.Timeout != 10*time.Second { + t.Fatalf("client timeout = %v", client.Timeout) + } + request := httptest.NewRequest(http.MethodGet, "https://example.com/next", nil) + if err := client.CheckRedirect(request, nil); err == nil { + t.Fatal("notification client followed a redirect") + } +} + +func TestRouteSettingsAPIReturnsFalseForUnknownPath(t *testing.T) { + test := newSettingsAPITest(t) + request := httptest.NewRequest(http.MethodGet, "/api/not-settings", nil) + if test.server.routeSettingsAPI(httptest.NewRecorder(), request, "not-settings") { + t.Fatal("unknown path was claimed by settings router") + } +} diff --git a/internal/server/sms_api.go b/internal/server/sms_api.go new file mode 100644 index 0000000..e82164d --- /dev/null +++ b/internal/server/sms_api.go @@ -0,0 +1,662 @@ +package server + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "vocat/internal/device" + "vocat/internal/store" + "vocat/internal/vowifi" +) + +type imsSMSController interface { + SendSMS(context.Context, string, vowifi.SMSSubmitRequest) (vowifi.SMSSubmitResult, error) +} + +func (s *Server) routeSMSAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool { + switch cleanPath { + case "sms/contacts": + s.handleSMSContacts(w, r) + case "sms/thread": + s.handleSMSThread(w, r) + case "sms/send": + s.handleSMSSend(w, r) + default: + segments := splitAPIPath(cleanPath) + if len(segments) == 3 && segments[0] == "sms" && segments[1] == "messages" { + s.handleSMSMessage(w, r, segments[2]) + return true + } + return false + } + return true +} + +func (s *Server) handleSMSContacts(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodGet) { + return + } + deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id")) + s.syncModemSMS(r.Context(), deviceID) + contacts, err := s.store.ListSMSContacts(r.Context(), store.SMSFilter{ + DeviceID: deviceID, + Limit: queryLimit(r, 100), + }) + if err != nil { + s.writeStoreError(w, err) + return + } + result := make([]map[string]any, 0, len(contacts)) + for _, contact := range contacts { + result = append(result, map[string]any{ + "device_id": contact.DeviceID, + "device_name": contact.DeviceName, + "imsi": contact.IMSI, + "local_phone": contact.LocalPhone, + "peer": contact.Peer, + "display_name": contact.DisplayName, + "last_message": contact.LastMessage, + "last_content": contact.LastMessage, + "last_timestamp": contact.LastTimestamp, + "direction": contact.Direction, + "last_type": "sms", + "last_sms_id": contact.LastSMSID, + "unread_count": contact.UnreadCount, + "message_count": contact.MessageCount, + }) + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) +} + +func (s *Server) handleSMSThread(w http.ResponseWriter, r *http.Request) { + deviceID := normalizeSMSDeviceFilter(r.URL.Query().Get("device_id")) + imsi := strings.TrimSpace(r.URL.Query().Get("imsi")) + peer := strings.TrimSpace(r.URL.Query().Get("peer")) + if peer == "" { + writeError(w, http.StatusBadRequest, "invalid_peer", "SMS peer is required") + return + } + switch r.Method { + case http.MethodGet: + s.syncModemSMS(r.Context(), deviceID) + messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{ + DeviceID: deviceID, + IMSI: imsi, + Peer: peer, + Limit: queryLimit(r, 100), + }) + if err != nil { + s.writeStoreError(w, err) + return + } + for _, message := range messages { + if !message.Read && (message.Direction == "inbound" || message.Direction == "received") { + message.Read = true + _, _ = s.store.SaveSMSMessage(r.Context(), message) + } + } + reverseSMS(messages) + result := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + result = append(result, storedSMSResponse(message)) + } + writeJSON(w, http.StatusOK, map[string]any{"data": result}) + case http.MethodDelete: + messages, err := s.store.ListSMSMessages(r.Context(), store.SMSFilter{ + DeviceID: deviceID, + IMSI: imsi, + Peer: peer, + Limit: 1000, + }) + if err != nil { + s.writeStoreError(w, err) + return + } + if len(messages) == 0 { + writeError(w, http.StatusNotFound, "not_found", "SMS thread was not found") + return + } + for _, message := range messages { + if err := s.store.DeleteSMSMessage(r.Context(), message.ID); err != nil { + s.writeStoreError(w, err) + return + } + } + writeJSON(w, http.StatusOK, map[string]any{ + "data": map[string]any{"deleted": len(messages)}, + }) + default: + w.Header().Set("Allow", "GET, DELETE") + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed") + } +} + +func normalizeSMSDeviceFilter(value string) string { + value = strings.TrimSpace(value) + if strings.EqualFold(value, "all") { + return "" + } + return value +} + +// blockedSMSDestination reports whether the recipient is in a barred country. +// Normalization mirrors the PDU/IMS paths so the block cannot be sidestepped by +// dropping the leading "+" or using a 00 international prefix. +func blockedSMSDestination(phone string) (bool, string) { + var digits strings.Builder + for _, c := range strings.TrimSpace(phone) { + if c >= '0' && c <= '9' { + digits.WriteRune(c) + } + } + d := digits.String() + if strings.HasPrefix(d, "00") { + d = d[2:] + } + if strings.HasPrefix(d, "86") { + return true, "SMS to +86 (China) destinations is not allowed" + } + return false, "" +} + +func (s *Server) handleSMSSend(w http.ResponseWriter, r *http.Request) { + if !requireMethod(w, r, http.MethodPost) { + return + } + if s.devices == nil { + writeError(w, http.StatusServiceUnavailable, "device_manager_unavailable", "device manager is unavailable") + return + } + var request struct { + Phone string `json:"phone"` + Message string `json:"message"` + DeviceID string `json:"device_id"` + } + if err := s.decodeJSON(w, r, &request); err != nil { + writeError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + request.DeviceID = strings.TrimSpace(request.DeviceID) + if request.DeviceID == "" { + writeError(w, http.StatusBadRequest, "device_required", "a sending device is required") + return + } + if blocked, reason := blockedSMSDestination(request.Phone); blocked { + writeError(w, http.StatusBadRequest, "blocked_destination", reason) + return + } + config, err := s.store.Device(r.Context(), request.DeviceID) + if err != nil { + s.writeStoreError(w, err) + return + } + entry, physicalID, present := s.physicalForConfig(config) + if !s.requirePhysicalDevice(w, present) { + return + } + if config.VoWiFiEnabled && s.vowifi != nil { + state, stateErr := s.vowifi.State(request.DeviceID) + sender, canSendIMS := s.vowifi.(imsSMSController) + if stateErr == nil && state.IMSReady && state.SMSReady && canSendIMS { + result, sendErr := sender.SendSMS(r.Context(), request.DeviceID, vowifi.SMSSubmitRequest{ + Recipient: request.Phone, + Text: request.Message, + }) + if sendErr == nil || result.PartsAttempted > 0 || !errors.Is(sendErr, vowifi.ErrSMSNotReady) { + s.writeIMSSMSSendResult(w, r, request.DeviceID, request.Message, entry, result, sendErr) + return + } + } + } + result, sendErr := s.devices.SendSMS( + r.Context(), + physicalID, + request.Phone, + request.Message, + ) + if sendErr != nil && result.PartsAttempted == 0 { + s.writeDeviceError(w, sendErr) + return + } + imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI }) + extra, _ := json.Marshal(map[string]any{ + "encoding": result.Encoding, + "message_reference": result.MessageReference, + "reference_known": result.ReferenceKnown, + "accepted_by_modem": result.AcceptedByModem, + "delivery_confirmed": result.DeliveryConfirmed, + "submission_status": result.SubmissionStatus, + "modem_final": result.ModemFinal, + "modem_evidence_count": len(result.ModemEvidence), + "parts_total": result.PartsTotal, + "parts_attempted": result.PartsAttempted, + "parts_accepted": result.PartsAccepted, + "all_parts_accepted": result.AllPartsAccepted, + "concat_reference": result.ConcatReference, + "part_results": result.PartResults, + }) + messageID := fmt.Sprintf( + "at-submit:%s:%d:%d", + request.DeviceID, + result.MessageReference, + result.SubmittedAt.UnixNano(), + ) + saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{ + MessageID: messageID, + DeviceID: request.DeviceID, + IMSI: imsi, + Peer: result.To, + Direction: "outbound", + Body: request.Message, + Timestamp: result.SubmittedAt, + Status: result.SubmissionStatus, + Source: "cellular_at", + PartsTotal: result.PartsTotal, + DeliveryState: result.DeliveryStatus, + Read: true, + Extra: extra, + }) + if err != nil { + s.writeStoreError(w, err) + return + } + data := map[string]any{ + "message_id": saved.MessageID, + "id": saved.ID, + "parts_total": saved.PartsTotal, + "parts_attempted": result.PartsAttempted, + "parts_accepted": result.PartsAccepted, + "all_parts_accepted": result.AllPartsAccepted, + "concat_reference": result.ConcatReference, + "part_results": result.PartResults, + "delivery_state": saved.DeliveryState, + "submission_state": saved.Status, + "message_reference": result.MessageReference, + "reference_known": result.ReferenceKnown, + "submission_accepted": result.AllPartsAccepted, + "delivery_confirmed": result.DeliveryConfirmed, + "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed), + "transport": "cellular_at", + } + if sendErr != nil { + data["retry_safe"] = false + if result.PartsAccepted > 0 { + data["warning"] = "Only part of the multipart SMS was accepted by the modem. Do not retry the whole message." + writeJSON(w, http.StatusAccepted, map[string]any{"data": data}) + return + } + s.logger.Warn( + "SMS submission failed after modem interaction", + "device_id", request.DeviceID, + "parts_attempted", result.PartsAttempted, + "parts_accepted", result.PartsAccepted, + "error", sendErr, + ) + writeJSON(w, http.StatusBadGateway, map[string]any{ + "error": apiError{ + Code: "sms_submission_failed", + Message: "The modem did not provide complete proof that the SMS was accepted. Inspect part_results before retrying.", + }, + "data": data, + }) + return + } + if !result.AllPartsAccepted { + writeJSON(w, http.StatusBadGateway, map[string]any{ + "error": apiError{ + Code: "sms_submission_unconfirmed", + Message: "The modem did not confirm acceptance of every SMS part.", + }, + "data": data, + }) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"data": data}) +} + +func (s *Server) writeIMSSMSSendResult( + w http.ResponseWriter, + r *http.Request, + deviceID string, + body string, + entry device.Device, + result vowifi.SMSSubmitResult, + sendErr error, +) { + if sendErr != nil && result.PartsAttempted == 0 { + if errors.Is(sendErr, device.ErrSMSInvalidRecipient) || + errors.Is(sendErr, device.ErrSMSEmpty) || + errors.Is(sendErr, device.ErrSMSTooLong) { + s.writeDeviceError(w, sendErr) + return + } + writeError(w, http.StatusBadGateway, "ims_sms_submission_failed", sendErr.Error()) + return + } + extra, _ := json.Marshal(map[string]any{ + "transport": "ims", + "encoding": result.Encoding, + "parts_total": result.PartsTotal, + "parts_attempted": result.PartsAttempted, + "parts_accepted": result.PartsAccepted, + "all_parts_accepted": result.AllPartsAccepted, + "concat_reference": result.ConcatReference, + "part_results": result.PartResults, + "delivery_confirmed": result.DeliveryConfirmed, + "submission_status": result.SubmissionStatus, + }) + imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI }) + saved, err := s.store.SaveSMSMessage(r.Context(), store.SMSMessage{ + MessageID: fmt.Sprintf("ims-submit:%s:%d", deviceID, result.SubmittedAt.UnixNano()), + DeviceID: deviceID, + IMSI: imsi, + Peer: result.To, + Direction: "outbound", + Body: body, + Timestamp: result.SubmittedAt, + Status: result.SubmissionStatus, + Source: "ims", + PartsTotal: result.PartsTotal, + DeliveryState: imsSMSDeliveryState(result), + Read: true, + Extra: extra, + }) + if err != nil { + s.writeStoreError(w, err) + return + } + data := map[string]any{ + "message_id": saved.MessageID, + "id": saved.ID, + "parts_total": result.PartsTotal, + "parts_attempted": result.PartsAttempted, + "parts_accepted": result.PartsAccepted, + "all_parts_accepted": result.AllPartsAccepted, + "concat_reference": result.ConcatReference, + "part_results": result.PartResults, + "delivery_state": saved.DeliveryState, + "submission_state": saved.Status, + "transport": "ims", + "submission_accepted": result.AllPartsAccepted, + "delivery_confirmed": result.DeliveryConfirmed, + "outcome": smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed), + } + if sendErr != nil { + data["retry_safe"] = false + data["warning"] = sendErr.Error() + if result.PartsAccepted == 0 { + writeJSON(w, http.StatusBadGateway, map[string]any{ + "error": apiError{ + Code: "ims_sms_submission_failed", + Message: "IMS did not accept the SMS submission.", + }, + "data": data, + }) + return + } + } + if !result.AllPartsAccepted && result.PartsAccepted == 0 { + writeJSON(w, http.StatusBadGateway, map[string]any{ + "error": apiError{ + Code: "ims_sms_submission_unconfirmed", + Message: "IMS did not confirm acceptance of every SMS part.", + }, + "data": data, + }) + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"data": data}) +} + +func smsSendOutcome(allAccepted bool, partsAccepted, partsTotal int, deliveryConfirmed bool) string { + switch { + case deliveryConfirmed: + return "delivered" + case allAccepted && partsTotal > 0 && partsAccepted == partsTotal: + return "accepted_unconfirmed" + case partsAccepted > 0: + return "partial" + default: + return "failed" + } +} + +func imsSMSDeliveryState(result vowifi.SMSSubmitResult) string { + switch smsSendOutcome(result.AllPartsAccepted, result.PartsAccepted, result.PartsTotal, result.DeliveryConfirmed) { + case "delivered": + return "delivered" + case "accepted_unconfirmed": + return "accepted_by_ims" + case "partial": + return "partial" + default: + return "failed" + } +} + +func (s *Server) handleSMSMessage(w http.ResponseWriter, r *http.Request, idText string) { + if !requireMethod(w, r, http.MethodDelete) { + return + } + id, err := strconv.ParseInt(idText, 10, 64) + if err != nil || id < 1 { + writeError(w, http.StatusBadRequest, "invalid_sms_id", "SMS message ID must be a positive integer") + return + } + if err := s.store.DeleteSMSMessage(r.Context(), id); err != nil { + s.writeStoreError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"deleted": true}}) +} + +func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) { + if s.devices == nil { + return + } + configs, err := s.store.ListDevices(ctx) + if err != nil { + s.logger.Warn("list devices for SMS synchronization failed", "error", err) + return + } + for _, config := range configs { + if onlyDevice != "" && config.ID != onlyDevice { + continue + } + // Do not queue CMGL traffic on the same serial actor while VoWiFi is + // reading the SIM or running AKA. Once the session is stable, resume the + // SM/ME scan as a catch-up path: an SMS submitted while the card was + // offline may be delivered to modem storage when it comes back, even + // though subsequent live SMS is delivered by SIP MESSAGE. + if s.vowifi != nil { + state, stateErr := s.vowifi.State(config.ID) + if shouldDeferModemSMSSync(state, stateErr) { + continue + } + } + entry, physicalID, present := s.physicalForConfig(config) + if !present { + continue + } + listContext, cancelList := context.WithTimeout(ctx, 30*time.Second) + messages, err := s.devices.ListSMS(listContext, physicalID) + cancelList() + if err != nil { + s.logger.Debug("modem SMS synchronization skipped", "device_id", config.ID, "error", err) + continue + } + imsi := snapshotString(entry.Snapshot, func(snapshot *device.Snapshot) string { return snapshot.IMSI }) + for _, message := range messages { + if message.Direction == device.SMSDirectionStatusReport && + message.MessageReference != nil && message.StatusCode != nil { + _, applyErr := s.store.ApplySMSDeliveryReport(ctx, store.SMSDeliveryReport{ + DeviceID: config.ID, + IMSI: imsi, + Peer: message.To, + Source: "cellular_at", + MessageReference: *message.MessageReference, + StatusCode: *message.StatusCode, + DeliveryState: message.DeliveryStatus, + ServiceCenterTime: message.ServiceCenterTimestamp, + DischargeTime: message.DischargeTimestamp, + ReceivedAt: time.Now().UTC(), + }) + if applyErr != nil && !errors.Is(applyErr, store.ErrNotFound) { + s.logger.Warn("apply modem SMS delivery report failed", "device_id", config.ID, "error", applyErr) + } + continue + } + peer := firstNonEmpty(message.From, message.To) + if peer == "" { + continue + } + timestamp := time.Now().UTC() + if message.ServiceCenterTimestamp != nil { + timestamp = message.ServiceCenterTimestamp.UTC() + } else if message.DischargeTimestamp != nil { + timestamp = message.DischargeTimestamp.UTC() + } + direction := "inbound" + if message.Direction == device.SMSDirectionSubmitted { + direction = "outbound" + } + digest := sha256.Sum256([]byte(message.RawPDU)) + messageID := fmt.Sprintf( + "modem:%s:%d:%s", + message.Storage, + message.Index, + hex.EncodeToString(digest[:8]), + ) + extra, _ := json.Marshal(map[string]any{ + "modem_index": message.Index, + "storage": message.Storage, + "storage_status": message.StorageStatus, + "encoding": message.Encoding, + "concat": message.Concat, + "decode_error": message.DecodeError, + "status_code": message.StatusCode, + "message_reference": message.MessageReference, + "delivery_status": message.DeliveryStatus, + "data_coding_scheme": message.DataCodingScheme, + }) + _, saveErr := s.store.SaveSMSMessage(ctx, store.SMSMessage{ + MessageID: messageID, + DeviceID: config.ID, + IMSI: imsi, + Peer: peer, + Direction: direction, + Body: message.Text, + Timestamp: timestamp, + Status: string(message.StorageStatus), + Source: "cellular_at", + PartsTotal: concatTotal(message.Concat), + DeliveryState: message.DeliveryStatus, + Read: message.StorageStatus == device.SMSStatusReceivedRead, + Extra: extra, + }) + if saveErr != nil { + s.logger.Warn("persist modem SMS failed", "device_id", config.ID, "error", saveErr) + } + } + } +} + +func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool { + if stateErr != nil || !state.Enabled { + return false + } + // SMSReady is a quiescent runtime state: SIM/AKA setup has finished and + // reading stored messages cannot race the eSIM/VoWiFi startup sequence. + // Failed is also safe because the orchestrator has restored cellular radio + // operation before publishing the terminal failure state. + return state.Phase != vowifi.PhaseSMSReady && state.Phase != vowifi.PhaseFailed +} + +// StartSMSSyncLoop periodically persists inbound cellular SMS even when no +// client has the SMS page open. The first tick is delayed so startup SIM/AKA +// work gets exclusive use of the modem. Stable VoWiFi sessions still scan SM +// and ME as a catch-up path for messages delivered while the card was offline. +func (s *Server) StartSMSSyncLoop(ctx context.Context, interval time.Duration) { + if interval <= 0 { + interval = 15 * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.syncModemSMS(ctx, "") + } + } +} + +func storedSMSResponse(message store.SMSMessage) map[string]any { + return map[string]any{ + "id": message.ID, + "message_id": message.MessageID, + "device_id": message.DeviceID, + "imsi": message.IMSI, + "peer": message.Peer, + "direction": message.Direction, + "body": message.Body, + "content": message.Body, + "sender": ternaryString(message.Direction == "outbound", "", message.Peer), + "recipient": ternaryString(message.Direction == "outbound", message.Peer, ""), + "type": "sms", + "timestamp": message.Timestamp, + "status": message.Status, + "source": message.Source, + "parts_total": message.PartsTotal, + "delivery_state": message.DeliveryState, + } +} + +func reverseSMS(messages []store.SMSMessage) { + for left, right := 0, len(messages)-1; left < right; left, right = left+1, right-1 { + messages[left], messages[right] = messages[right], messages[left] + } +} + +func concatTotal(value *device.SMSConcatInfo) int { + if value == nil || value.Total < 1 { + return 1 + } + return value.Total +} + +func ternaryString(condition bool, yes string, no string) string { + if condition { + return yes + } + return no +} + +func queryLimit(r *http.Request, fallback int) int { + value, err := strconv.Atoi(r.URL.Query().Get("limit")) + if err != nil || value < 1 { + return fallback + } + if value > 1000 { + return 1000 + } + return value +} + +func (s *Server) writeStoreError(w http.ResponseWriter, err error) { + if errors.Is(err, store.ErrNotFound) { + writeError(w, http.StatusNotFound, "not_found", "the requested record was not found") + return + } + s.logger.Error("database operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "database_error", "the database operation failed") +} diff --git a/internal/server/sms_api_test.go b/internal/server/sms_api_test.go new file mode 100644 index 0000000..2ba68af --- /dev/null +++ b/internal/server/sms_api_test.go @@ -0,0 +1,115 @@ +package server + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "vocat/internal/store" +) + +func TestSMSThreadAllDevicesUsesIMSIFilter(t *testing.T) { + ctx := context.Background() + database, err := store.Open(ctx, ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + for index, imsi := range []string{"imsi-a", "imsi-b"} { + if _, err := database.SaveSMSMessage(ctx, store.SMSMessage{ + MessageID: "message-" + imsi, + DeviceID: "ec20", + IMSI: imsi, + Peer: "VOXI", + Direction: "inbound", + Body: imsi, + Timestamp: time.Unix(1_700_000_000+int64(index), 0), + }); err != nil { + t.Fatal(err) + } + } + + server := &Server{store: database} + request := httptest.NewRequest( + http.MethodGet, + "/api/sms/thread?device_id=all&imsi=imsi-a&peer=VOXI", + nil, + ) + response := httptest.NewRecorder() + server.handleSMSThread(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) + } + var envelope struct { + Data []map[string]any `json:"data"` + } + if err := json.Unmarshal(response.Body.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if len(envelope.Data) != 1 || envelope.Data[0]["imsi"] != "imsi-a" { + t.Fatalf("thread data = %#v", envelope.Data) + } +} + +func TestNormalizeSMSDeviceFilter(t *testing.T) { + if got := normalizeSMSDeviceFilter(" ALL "); got != "" { + t.Fatalf("all filter = %q", got) + } + if got := normalizeSMSDeviceFilter("EC20"); got != "EC20" { + t.Fatalf("device filter = %q", got) + } +} + +func TestSMSSendOutcome(t *testing.T) { + tests := []struct { + name string + all bool + accepted int + total int + delivered bool + want string + }{ + {name: "delivered", all: true, accepted: 1, total: 1, delivered: true, want: "delivered"}, + {name: "accepted but unconfirmed", all: true, accepted: 2, total: 2, want: "accepted_unconfirmed"}, + {name: "partial", accepted: 1, total: 2, want: "partial"}, + {name: "failed", total: 1, want: "failed"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := smsSendOutcome(test.all, test.accepted, test.total, test.delivered); got != test.want { + t.Fatalf("smsSendOutcome() = %q, want %q", got, test.want) + } + }) + } +} + +func TestBlockedSMSDestination(t *testing.T) { + tests := []struct { + name string + phone string + block bool + }{ + {"e164 china", "+8613800138000", true}, + {"no plus china", "8613800138000", true}, + {"international prefix china", "008613800138000", true}, + {"spaced china", "+86 138 0013 8000", true}, + {"dashed china", "+86-138-0013-8000", true}, + {"us e164", "+12025550177", false}, + {"us no plus", "12025550177", false}, + {"uk e164", "+447700900123", false}, + {"italy", "+393331234567", false}, + {"russia", "+79161234567", false}, + {"japan", "+819012345678", false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + blocked, _ := blockedSMSDestination(test.phone) + if blocked != test.block { + t.Fatalf("blockedSMSDestination(%q) blocked = %v, want %v", test.phone, blocked, test.block) + } + }) + } +} diff --git a/internal/store/devices.go b/internal/store/devices.go new file mode 100644 index 0000000..789bc03 --- /dev/null +++ b/internal/store/devices.go @@ -0,0 +1,578 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +type contextExecer interface { + ExecContext(context.Context, string, ...any) (sql.Result, error) +} + +func (s *Store) UpsertDevice(ctx context.Context, value Device) error { + return upsertDevice(ctx, s.db, value) +} + +// SaveDeviceState stores configuration and the supplied runtime snapshots in +// one transaction. A nil runtime leaves that snapshot untouched. +func (s *Store) SaveDeviceState( + ctx context.Context, + value Device, + runtime *DeviceRuntime, + vowifi *VoWiFiRuntime, +) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin device state update: %w", err) + } + defer tx.Rollback() + + if err := upsertDevice(ctx, tx, value); err != nil { + return err + } + if runtime != nil { + snapshot := *runtime + if snapshot.DeviceID == "" { + snapshot.DeviceID = value.ID + } + if snapshot.DeviceID != value.ID { + return errors.New("device runtime belongs to a different device") + } + if err := upsertDeviceRuntime(ctx, tx, snapshot); err != nil { + return err + } + } + if vowifi != nil { + snapshot := *vowifi + if snapshot.DeviceID == "" { + snapshot.DeviceID = value.ID + } + if snapshot.DeviceID != value.ID { + return errors.New("VoWiFi runtime belongs to a different device") + } + if err := upsertVoWiFiRuntime(ctx, tx, snapshot); err != nil { + return err + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit device state update: %w", err) + } + return nil +} + +func upsertDevice(ctx context.Context, executor contextExecer, value Device) error { + value.ID = strings.TrimSpace(value.ID) + value.Name = strings.TrimSpace(value.Name) + if value.ID == "" { + return errors.New("device id is required") + } + if value.Name == "" { + return errors.New("device name is required") + } + if value.ProxyPort < 0 || value.ProxyPort > 65535 { + return errors.New("device proxy port must be between 0 and 65535") + } + if value.BaudRate == 0 { + value.BaudRate = 115200 + } + if value.BaudRate < 1 { + return errors.New("device baud rate must be positive") + } + if value.DataBits == 0 { + value.DataBits = 8 + } + if value.DataBits < 5 || value.DataBits > 8 { + return errors.New("device data bits must be between 5 and 8") + } + if value.StopBits == 0 { + value.StopBits = 1 + } + if value.StopBits != 1 && value.StopBits != 2 { + return errors.New("device stop bits must be 1 or 2") + } + if value.Parity == "" { + value.Parity = "none" + } + value.Parity = strings.ToLower(strings.TrimSpace(value.Parity)) + switch value.Parity { + case "none", "even", "odd", "mark", "space": + default: + return fmt.Errorf("unsupported device parity %q", value.Parity) + } + if value.DeviceBackend == "" { + value.DeviceBackend = "at" + } + value.DeviceBackend = strings.ToLower(strings.TrimSpace(value.DeviceBackend)) + if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" { + return fmt.Errorf("unsupported device backend %q", value.DeviceBackend) + } + if value.ESIMTransport == "" { + value.ESIMTransport = "at" + } + value.ESIMTransport = strings.ToLower(strings.TrimSpace(value.ESIMTransport)) + if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" { + return fmt.Errorf("unsupported eSIM transport %q", value.ESIMTransport) + } + extra, err := normalizeJSONObject(value.Extra) + if err != nil { + return fmt.Errorf("normalize device extra data: %w", err) + } + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + + _, err = executor.ExecContext(ctx, ` + INSERT INTO devices ( + id, name, interface, control_device, at_port, usb_path, + audio_device, modem_imei, apn, proxy_port, baud_rate, + data_bits, stop_bits, parity, device_backend, esim_transport, + qmi_use_proxy, qmi_proxy_path, qmi_proxy_executable, + network_enabled, sms_enabled, vowifi_enabled, extra_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + interface = excluded.interface, + control_device = excluded.control_device, + at_port = excluded.at_port, + usb_path = excluded.usb_path, + audio_device = excluded.audio_device, + modem_imei = excluded.modem_imei, + apn = excluded.apn, + proxy_port = excluded.proxy_port, + baud_rate = excluded.baud_rate, + data_bits = excluded.data_bits, + stop_bits = excluded.stop_bits, + parity = excluded.parity, + device_backend = excluded.device_backend, + esim_transport = excluded.esim_transport, + qmi_use_proxy = excluded.qmi_use_proxy, + qmi_proxy_path = excluded.qmi_proxy_path, + qmi_proxy_executable = excluded.qmi_proxy_executable, + network_enabled = excluded.network_enabled, + sms_enabled = excluded.sms_enabled, + vowifi_enabled = excluded.vowifi_enabled, + extra_json = excluded.extra_json, + updated_at = excluded.updated_at + `, + value.ID, value.Name, value.Interface, value.ControlDevice, value.ATPort, + value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN, + value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits, + value.Parity, value.DeviceBackend, value.ESIMTransport, + boolInt(value.QMIUseProxy), value.QMIProxyPath, value.QMIProxyExecutable, + boolInt(value.NetworkEnabled), boolInt(value.SMSEnabled), + boolInt(value.VoWiFiEnabled), string(extra), createdAt.Unix(), + updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert device %q: %w", value.ID, err) + } + return nil +} + +func (s *Store) Device(ctx context.Context, id string) (Device, error) { + return scanDevice(s.db.QueryRowContext(ctx, deviceSelect+` WHERE id = ?`, id)) +} + +func (s *Store) ListDevices(ctx context.Context) ([]Device, error) { + rows, err := s.db.QueryContext(ctx, deviceSelect+` ORDER BY name COLLATE NOCASE, id`) + if err != nil { + return nil, fmt.Errorf("list devices: %w", err) + } + defer rows.Close() + + values := make([]Device, 0) + for rows.Next() { + value, err := scanDevice(rows) + if err != nil { + return nil, fmt.Errorf("scan device: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate devices: %w", err) + } + return values, nil +} + +func (s *Store) DeleteDevice(ctx context.Context, id string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM devices WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete device %q: %w", id, err) + } + return requireAffected(result) +} + +const deviceSelect = ` + SELECT id, name, interface, control_device, at_port, usb_path, + audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits, + stop_bits, parity, device_backend, esim_transport, qmi_use_proxy, + qmi_proxy_path, qmi_proxy_executable, network_enabled, sms_enabled, + vowifi_enabled, extra_json, created_at, updated_at + FROM devices` + +func scanDevice(row rowScanner) (Device, error) { + var value Device + var qmiUseProxy, networkEnabled, smsEnabled, vowifiEnabled int + var extra string + var createdAt, updatedAt int64 + err := row.Scan( + &value.ID, &value.Name, &value.Interface, &value.ControlDevice, + &value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, + &value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits, + &value.StopBits, &value.Parity, &value.DeviceBackend, + &value.ESIMTransport, &qmiUseProxy, &value.QMIProxyPath, + &value.QMIProxyExecutable, &networkEnabled, &smsEnabled, + &vowifiEnabled, &extra, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return Device{}, ErrNotFound + } + if err != nil { + return Device{}, err + } + value.QMIUseProxy = qmiUseProxy != 0 + value.NetworkEnabled = networkEnabled != 0 + value.SMSEnabled = smsEnabled != 0 + value.VoWiFiEnabled = vowifiEnabled != 0 + value.Extra = []byte(extra) + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func (s *Store) UpsertDeviceRuntime(ctx context.Context, value DeviceRuntime) error { + return upsertDeviceRuntime(ctx, s.db, value) +} + +func upsertDeviceRuntime( + ctx context.Context, + executor contextExecer, + value DeviceRuntime, +) error { + if strings.TrimSpace(value.DeviceID) == "" { + return errors.New("device runtime device id is required") + } + traffic, err := normalizeJSONObject(value.Traffic) + if err != nil { + return fmt.Errorf("normalize device traffic: %w", err) + } + extra, err := normalizeJSONObject(value.Extra) + if err != nil { + return fmt.Errorf("normalize device runtime extra data: %w", err) + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + _, err = executor.ExecContext(ctx, ` + INSERT INTO device_runtime ( + device_id, running, healthy, control_online, physical_present, + worker_running, data_connected, radio_registered, network_connected, + flight_mode, lifecycle_phase, lifecycle_reason, public_ip, private_ip, + operator, native_mcc, native_mnc, native_spn, network_mode, + network_duplex, radio_band, radio_channel, signal_dbm, signal_rsrp, + signal_rsrq, signal_sinr, imei, iccid, imsi, firmware, reg_status, + reg_status_text, ps_attached, sim_inserted, operating_mode, + phone_number, phone_number_source, traffic_json, extra_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id) DO UPDATE SET + running = excluded.running, + healthy = excluded.healthy, + control_online = excluded.control_online, + physical_present = excluded.physical_present, + worker_running = excluded.worker_running, + data_connected = excluded.data_connected, + radio_registered = excluded.radio_registered, + network_connected = excluded.network_connected, + flight_mode = excluded.flight_mode, + lifecycle_phase = excluded.lifecycle_phase, + lifecycle_reason = excluded.lifecycle_reason, + public_ip = excluded.public_ip, + private_ip = excluded.private_ip, + operator = excluded.operator, + native_mcc = excluded.native_mcc, + native_mnc = excluded.native_mnc, + native_spn = excluded.native_spn, + network_mode = excluded.network_mode, + network_duplex = excluded.network_duplex, + radio_band = excluded.radio_band, + radio_channel = excluded.radio_channel, + signal_dbm = excluded.signal_dbm, + signal_rsrp = excluded.signal_rsrp, + signal_rsrq = excluded.signal_rsrq, + signal_sinr = excluded.signal_sinr, + imei = excluded.imei, + iccid = excluded.iccid, + imsi = excluded.imsi, + firmware = excluded.firmware, + reg_status = excluded.reg_status, + reg_status_text = excluded.reg_status_text, + ps_attached = excluded.ps_attached, + sim_inserted = excluded.sim_inserted, + operating_mode = excluded.operating_mode, + phone_number = excluded.phone_number, + phone_number_source = excluded.phone_number_source, + traffic_json = excluded.traffic_json, + extra_json = excluded.extra_json, + updated_at = excluded.updated_at + `, + value.DeviceID, boolInt(value.Running), boolInt(value.Healthy), + boolInt(value.ControlOnline), boolInt(value.PhysicalPresent), + boolInt(value.WorkerRunning), boolInt(value.DataConnected), + boolInt(value.RadioRegistered), boolInt(value.NetworkConnected), + boolInt(value.FlightMode), value.LifecyclePhase, value.LifecycleReason, + value.PublicIP, value.PrivateIP, value.Operator, value.NativeMCC, + value.NativeMNC, value.NativeSPN, value.NetworkMode, + value.NetworkDuplex, value.RadioBand, value.RadioChannel, + value.SignalDBM, nullableInt(value.SignalRSRP), nullableInt(value.SignalRSRQ), + nullableInt(value.SignalSINR), value.IMEI, value.ICCID, value.IMSI, + value.Firmware, value.RegStatus, value.RegStatusText, + nullableBool(value.PSAttached), nullableBool(value.SIMInserted), + nullableInt(value.OperatingMode), value.PhoneNumber, + value.PhoneNumberSource, string(traffic), string(extra), updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert runtime for device %q: %w", value.DeviceID, err) + } + return nil +} + +func (s *Store) DeviceRuntime(ctx context.Context, deviceID string) (DeviceRuntime, error) { + return scanDeviceRuntime(s.db.QueryRowContext(ctx, deviceRuntimeSelect+` WHERE device_id = ?`, deviceID)) +} + +const deviceRuntimeSelect = ` + SELECT device_id, running, healthy, control_online, physical_present, + worker_running, data_connected, radio_registered, network_connected, + flight_mode, lifecycle_phase, lifecycle_reason, public_ip, private_ip, + operator, native_mcc, native_mnc, native_spn, network_mode, + network_duplex, radio_band, radio_channel, signal_dbm, signal_rsrp, + signal_rsrq, signal_sinr, imei, iccid, imsi, firmware, reg_status, + reg_status_text, ps_attached, sim_inserted, operating_mode, + phone_number, phone_number_source, traffic_json, extra_json, updated_at + FROM device_runtime` + +func scanDeviceRuntime(row rowScanner) (DeviceRuntime, error) { + var value DeviceRuntime + var running, healthy, controlOnline, physicalPresent int + var workerRunning, dataConnected, radioRegistered, networkConnected int + var flightMode int + var signalRSRP, signalRSRQ, signalSINR sql.NullInt64 + var psAttached, simInserted, operatingMode sql.NullInt64 + var traffic, extra string + var updatedAt int64 + err := row.Scan( + &value.DeviceID, &running, &healthy, &controlOnline, &physicalPresent, + &workerRunning, &dataConnected, &radioRegistered, &networkConnected, + &flightMode, &value.LifecyclePhase, &value.LifecycleReason, + &value.PublicIP, &value.PrivateIP, &value.Operator, &value.NativeMCC, + &value.NativeMNC, &value.NativeSPN, &value.NetworkMode, + &value.NetworkDuplex, &value.RadioBand, &value.RadioChannel, + &value.SignalDBM, &signalRSRP, &signalRSRQ, &signalSINR, &value.IMEI, + &value.ICCID, &value.IMSI, &value.Firmware, &value.RegStatus, + &value.RegStatusText, &psAttached, &simInserted, &operatingMode, + &value.PhoneNumber, &value.PhoneNumberSource, &traffic, &extra, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return DeviceRuntime{}, ErrNotFound + } + if err != nil { + return DeviceRuntime{}, err + } + value.Running = running != 0 + value.Healthy = healthy != 0 + value.ControlOnline = controlOnline != 0 + value.PhysicalPresent = physicalPresent != 0 + value.WorkerRunning = workerRunning != 0 + value.DataConnected = dataConnected != 0 + value.RadioRegistered = radioRegistered != 0 + value.NetworkConnected = networkConnected != 0 + value.FlightMode = flightMode != 0 + value.SignalRSRP = nullIntPointer(signalRSRP) + value.SignalRSRQ = nullIntPointer(signalRSRQ) + value.SignalSINR = nullIntPointer(signalSINR) + value.PSAttached = nullBoolPointer(psAttached) + value.SIMInserted = nullBoolPointer(simInserted) + value.OperatingMode = nullIntPointer(operatingMode) + value.Traffic = []byte(traffic) + value.Extra = []byte(extra) + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func (s *Store) DeleteDeviceRuntime(ctx context.Context, deviceID string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM device_runtime WHERE device_id = ?`, deviceID) + if err != nil { + return fmt.Errorf("delete runtime for device %q: %w", deviceID, err) + } + return requireAffected(result) +} + +func (s *Store) UpsertVoWiFiRuntime(ctx context.Context, value VoWiFiRuntime) error { + return upsertVoWiFiRuntime(ctx, s.db, value) +} + +func upsertVoWiFiRuntime( + ctx context.Context, + executor contextExecer, + value VoWiFiRuntime, +) error { + if strings.TrimSpace(value.DeviceID) == "" { + return errors.New("VoWiFi runtime device id is required") + } + tunnel, err := normalizeJSONObject(value.Tunnel) + if err != nil { + return fmt.Errorf("normalize VoWiFi tunnel state: %w", err) + } + imscore, err := normalizeJSONObject(value.IMSCore) + if err != nil { + return fmt.Errorf("normalize VoWiFi IMS state: %w", err) + } + smsip, err := normalizeJSONObject(value.SMSIP) + if err != nil { + return fmt.Errorf("normalize VoWiFi SMS state: %w", err) + } + extra, err := normalizeJSONObject(value.Extra) + if err != nil { + return fmt.Errorf("normalize VoWiFi extra state: %w", err) + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + _, err = executor.ExecContext(ctx, ` + INSERT INTO vowifi_runtime ( + device_id, phase, dataplane_mode, iccid, imsi, sim_ready, + access_ready, tunnel_ready, ims_ready, sms_ready, reg_status, + reg_status_text, network_mode, local_phone, phone_number_source, + last_error_class, last_error, last_reason, tunnel_json, + imscore_json, smsip_json, extra_json, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id) DO UPDATE SET + phase = excluded.phase, + dataplane_mode = excluded.dataplane_mode, + iccid = excluded.iccid, + imsi = excluded.imsi, + sim_ready = excluded.sim_ready, + access_ready = excluded.access_ready, + tunnel_ready = excluded.tunnel_ready, + ims_ready = excluded.ims_ready, + sms_ready = excluded.sms_ready, + reg_status = excluded.reg_status, + reg_status_text = excluded.reg_status_text, + network_mode = excluded.network_mode, + local_phone = excluded.local_phone, + phone_number_source = excluded.phone_number_source, + last_error_class = excluded.last_error_class, + last_error = excluded.last_error, + last_reason = excluded.last_reason, + tunnel_json = excluded.tunnel_json, + imscore_json = excluded.imscore_json, + smsip_json = excluded.smsip_json, + extra_json = excluded.extra_json, + updated_at = excluded.updated_at + `, + value.DeviceID, value.Phase, value.DataplaneMode, value.ICCID, + value.IMSI, boolInt(value.SIMReady), boolInt(value.AccessReady), + boolInt(value.TunnelReady), boolInt(value.IMSReady), + boolInt(value.SMSReady), value.RegStatus, value.RegStatusText, + value.NetworkMode, value.LocalPhone, value.PhoneNumberSource, + value.LastErrorClass, value.LastError, value.LastReason, + string(tunnel), string(imscore), string(smsip), string(extra), + updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert VoWiFi runtime for device %q: %w", value.DeviceID, err) + } + return nil +} + +func (s *Store) VoWiFiRuntime(ctx context.Context, deviceID string) (VoWiFiRuntime, error) { + return scanVoWiFiRuntime(s.db.QueryRowContext(ctx, vowifiRuntimeSelect+` WHERE device_id = ?`, deviceID)) +} + +const vowifiRuntimeSelect = ` + SELECT device_id, phase, dataplane_mode, iccid, imsi, sim_ready, + access_ready, tunnel_ready, ims_ready, sms_ready, reg_status, + reg_status_text, network_mode, local_phone, phone_number_source, + last_error_class, last_error, last_reason, tunnel_json, imscore_json, + smsip_json, extra_json, updated_at + FROM vowifi_runtime` + +func scanVoWiFiRuntime(row rowScanner) (VoWiFiRuntime, error) { + var value VoWiFiRuntime + var simReady, accessReady, tunnelReady, imsReady, smsReady int + var tunnel, imscore, smsip, extra string + var updatedAt int64 + err := row.Scan( + &value.DeviceID, &value.Phase, &value.DataplaneMode, &value.ICCID, + &value.IMSI, &simReady, &accessReady, &tunnelReady, &imsReady, + &smsReady, &value.RegStatus, &value.RegStatusText, &value.NetworkMode, + &value.LocalPhone, &value.PhoneNumberSource, &value.LastErrorClass, + &value.LastError, &value.LastReason, &tunnel, &imscore, &smsip, + &extra, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return VoWiFiRuntime{}, ErrNotFound + } + if err != nil { + return VoWiFiRuntime{}, err + } + value.SIMReady = simReady != 0 + value.AccessReady = accessReady != 0 + value.TunnelReady = tunnelReady != 0 + value.IMSReady = imsReady != 0 + value.SMSReady = smsReady != 0 + value.Tunnel = []byte(tunnel) + value.IMSCore = []byte(imscore) + value.SMSIP = []byte(smsip) + value.Extra = []byte(extra) + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func (s *Store) DeleteVoWiFiRuntime(ctx context.Context, deviceID string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM vowifi_runtime WHERE device_id = ?`, deviceID) + if err != nil { + return fmt.Errorf("delete VoWiFi runtime for device %q: %w", deviceID, err) + } + return requireAffected(result) +} + +func requireAffected(result sql.Result) error { + affected, err := result.RowsAffected() + if err != nil { + return err + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +func nullIntPointer(value sql.NullInt64) *int { + if !value.Valid { + return nil + } + result := int(value.Int64) + return &result +} + +func nullBoolPointer(value sql.NullInt64) *bool { + if !value.Valid { + return nil + } + result := value.Int64 != 0 + return &result +} diff --git a/internal/store/domain_test.go b/internal/store/domain_test.go new file mode 100644 index 0000000..1898165 --- /dev/null +++ b/internal/store/domain_test.go @@ -0,0 +1,595 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestMigrationFromAuthenticationSchema(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "migration.db") + raw, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + for _, statement := range migrationStatements(1) { + if _, err := raw.ExecContext(ctx, statement); err != nil { + t.Fatalf("create v1 schema: %v", err) + } + } + if _, err := raw.ExecContext(ctx, ` + INSERT INTO admins (id, username, password_hash, created_at, updated_at) + VALUES (1, 'legacy-admin', X'0102', 100, 100) + `); err != nil { + t.Fatal(err) + } + if _, err := raw.ExecContext(ctx, `PRAGMA user_version = 1`); err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + + database := openTestStore(t, path) + admin, err := database.CurrentAdmin(ctx) + if err != nil { + t.Fatalf("legacy admin missing after migration: %v", err) + } + if admin.Username != "legacy-admin" || !bytes.Equal(admin.PasswordHash, []byte{1, 2}) { + t.Fatalf("legacy admin changed during migration: %+v", admin) + } + var version int + if err := database.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&version); err != nil { + t.Fatal(err) + } + if version != schemaVersion { + t.Fatalf("schema version = %d, want %d", version, schemaVersion) + } + for _, table := range []string{ + "devices", "device_runtime", "vowifi_runtime", "sms_messages", + "local_proxy_config", "upstream_proxies", "country_rules", + "device_proxy_bindings", + "notification_settings", "app_settings", "audit_events", + "log_events", "card_policies", "traffic_buckets", + } { + var found string + err := database.db.QueryRowContext(ctx, ` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = ? + `, table).Scan(&found) + if err != nil || found != table { + t.Fatalf("migrated table %q missing: %v", table, err) + } + } +} + +func TestMigration4PreservesIMSRedeliveryAndUsesReceiptTime(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "ims-redelivery.db") + legacy := openTestStore(t, path) + mustSaveDevice(t, legacy, "ec20-1", "EC20") + smscTime := time.Unix(1_700_000_000, 0).UTC() + firstReceipt := smscTime.Add(2 * time.Hour) + rawTPDU := "040ED0D637396C7EBBCB000062808051715140" + for index, receivedAt := range []time.Time{firstReceipt, firstReceipt.Add(30 * time.Minute)} { + extra, err := json.Marshal(map[string]any{"raw_tpdu": rawTPDU, "call_id": index}) + if err != nil { + t.Fatal(err) + } + if _, err := legacy.SaveSMSMessage(ctx, SMSMessage{ + MessageID: fmt.Sprintf("legacy-call-%d", index), + DeviceID: "ec20-1", + Peer: "Vodafone", + Direction: "inbound", + Body: "same message", + Timestamp: smscTime, + Status: "received", + Source: "ims", + CreatedAt: receivedAt, + Extra: extra, + }); err != nil { + t.Fatal(err) + } + } + if _, err := legacy.db.ExecContext(ctx, `PRAGMA user_version = 3`); err != nil { + t.Fatal(err) + } + if err := legacy.Close(); err != nil { + t.Fatal(err) + } + + migrated := openTestStore(t, path) + messages, err := migrated.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"}) + if err != nil { + t.Fatal(err) + } + if len(messages) != 2 { + t.Fatalf("message count after migration = %d, want 2", len(messages)) + } + if !messages[0].Timestamp.Equal(firstReceipt.Add(30*time.Minute)) || + !messages[1].Timestamp.Equal(firstReceipt) { + t.Fatalf("message times = %v / %v, want both receipt times", messages[0].Timestamp, messages[1].Timestamp) + } + var extra map[string]any + if err := json.Unmarshal(messages[0].Extra, &extra); err != nil { + t.Fatal(err) + } + if extra["service_center_timestamp_unix"] != float64(smscTime.Unix()) { + t.Fatalf("service center time was not retained: %#v", extra) + } +} + +func TestDeviceStateRoundTripAndCascade(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + rsrp, rsrq, sinr := -95, -12, 15 + attached, inserted := true, true + mode := 1 + device := Device{ + ID: "ec20-1", + Name: "EC20 一号", + Interface: "wwan0", + ControlDevice: "/dev/cdc-wdm0", + ATPort: "/dev/ttyUSB2", + APN: "ims", + ProxyPort: 1080, + QMIUseProxy: true, + NetworkEnabled: true, + SMSEnabled: true, + VoWiFiEnabled: true, + Extra: json.RawMessage(`{"slot":1}`), + } + runtime := DeviceRuntime{ + Running: true, + Healthy: true, + ControlOnline: true, + NetworkConnected: true, + Operator: "China Mobile", + SignalDBM: -71, + SignalRSRP: &rsrp, + SignalRSRQ: &rsrq, + SignalSINR: &sinr, + ICCID: "8986000000000000000", + IMSI: "460001234567890", + PSAttached: &attached, + SIMInserted: &inserted, + OperatingMode: &mode, + PhoneNumber: "+8613800138000", + PhoneNumberSource: "cnum", + Traffic: json.RawMessage(`{"rx":"1 MiB"}`), + } + vowifi := VoWiFiRuntime{ + Phase: "sms_ready", + SIMReady: true, + AccessReady: true, + TunnelReady: true, + IMSReady: true, + SMSReady: true, + LocalPhone: "+8613800138000", + PhoneNumberSource: "ims", + Tunnel: json.RawMessage(`{"ifname":"ipsec0"}`), + } + if err := database.SaveDeviceState(ctx, device, &runtime, &vowifi); err != nil { + t.Fatalf("SaveDeviceState() error = %v", err) + } + + gotDevice, err := database.Device(ctx, device.ID) + if err != nil { + t.Fatal(err) + } + if gotDevice.BaudRate != 115200 || gotDevice.DataBits != 8 || + gotDevice.StopBits != 1 || gotDevice.DeviceBackend != "at" { + t.Fatalf("device defaults not applied: %+v", gotDevice) + } + gotRuntime, err := database.DeviceRuntime(ctx, device.ID) + if err != nil { + t.Fatal(err) + } + if gotRuntime.PhoneNumber != runtime.PhoneNumber || + gotRuntime.SignalRSRP == nil || *gotRuntime.SignalRSRP != rsrp || + gotRuntime.PSAttached == nil || !*gotRuntime.PSAttached { + t.Fatalf("runtime did not round trip: %+v", gotRuntime) + } + gotVoWiFi, err := database.VoWiFiRuntime(ctx, device.ID) + if err != nil { + t.Fatal(err) + } + if !gotVoWiFi.SMSReady || gotVoWiFi.LocalPhone != vowifi.LocalPhone { + t.Fatalf("VoWiFi runtime did not round trip: %+v", gotVoWiFi) + } + if err := database.DeleteDevice(ctx, device.ID); err != nil { + t.Fatal(err) + } + if _, err := database.DeviceRuntime(ctx, device.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("runtime should cascade on device deletion, got %v", err) + } + if _, err := database.VoWiFiRuntime(ctx, device.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("VoWiFi runtime should cascade on device deletion, got %v", err) + } +} + +func TestSMSPersistenceAndDerivedThreads(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + mustSaveDevice(t, database, "ec20-1", "客厅") + if err := database.UpsertDeviceRuntime(ctx, DeviceRuntime{ + DeviceID: "ec20-1", + PhoneNumber: "+8613800138000", + }); err != nil { + t.Fatal(err) + } + base := time.Unix(1_700_000_000, 0).UTC() + if err := database.SaveSMSMessages(ctx, []SMSMessage{ + { + MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000", + Peer: "10086", Direction: "inbound", Body: "第一条", + Timestamp: base, Status: "received", + }, + { + MessageID: "network-2", DeviceID: "ec20-1", IMSI: "46000", + Peer: "10086", Direction: "outbound", Body: "第二条", + Timestamp: base.Add(time.Minute), Status: "sent", Read: true, + }, + { + MessageID: "network-3", DeviceID: "ec20-1", IMSI: "46000", + Peer: "95533", Direction: "received", Body: "银行提醒", + Timestamp: base.Add(2 * time.Minute), Status: "received", + }, + }); err != nil { + t.Fatalf("SaveSMSMessages() error = %v", err) + } + + // A modem retry updates the stable external id instead of duplicating it. + if _, err := database.SaveSMSMessage(ctx, SMSMessage{ + MessageID: "network-1", DeviceID: "ec20-1", IMSI: "46000", + Peer: "10086", Direction: "inbound", Body: "第一条(完整)", + Timestamp: base, Status: "received", + }); err != nil { + t.Fatal(err) + } + messages, err := database.ListSMSMessages(ctx, SMSFilter{DeviceID: "ec20-1"}) + if err != nil { + t.Fatal(err) + } + if len(messages) != 3 { + t.Fatalf("message count = %d, want 3", len(messages)) + } + if !messages[2].Timestamp.Equal(base) { + t.Fatalf("retry changed the original message time to %v", messages[2].Timestamp) + } + contacts, err := database.ListSMSContacts(ctx, SMSFilter{DeviceID: "ec20-1"}) + if err != nil { + t.Fatal(err) + } + if len(contacts) != 2 || contacts[0].Peer != "95533" || + contacts[0].UnreadCount != 1 || contacts[1].Peer != "10086" || + contacts[1].MessageCount != 2 || contacts[1].UnreadCount != 1 || + contacts[1].LocalPhone != "+8613800138000" { + t.Fatalf("unexpected derived contacts: %+v", contacts) + } + marked, err := database.MarkSMSThreadRead(ctx, "ec20-1", "46000", "10086") + if err != nil || marked != 1 { + t.Fatalf("MarkSMSThreadRead() = %d, %v", marked, err) + } + contacts, err = database.ListSMSContacts(ctx, SMSFilter{Peer: "10086"}) + if err != nil { + t.Fatal(err) + } + if len(contacts) != 1 || contacts[0].UnreadCount != 0 { + t.Fatalf("thread should be read: %+v", contacts) + } + deleted, err := database.DeleteSMSThread(ctx, "ec20-1", "46000", "10086") + if err != nil || deleted != 2 { + t.Fatalf("DeleteSMSThread() = %d, %v", deleted, err) + } +} + +func TestApplySMSDeliveryReportTracksEverySubmittedPart(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + mustSaveDevice(t, database, "ec20-1", "EC20") + extra := json.RawMessage(`{ + "transport":"ims", + "part_results":[{"reference":42},{"reference":43}] + }`) + sent, err := database.SaveSMSMessage(ctx, SMSMessage{ + MessageID: "ims-submit-1", DeviceID: "ec20-1", IMSI: "23415", + Peer: "+447700900123", Direction: "outbound", Body: "multipart", + Timestamp: time.Now().UTC(), Status: "accepted_by_ims", Source: "ims", + PartsTotal: 2, DeliveryState: "accepted_by_ims", Read: true, Extra: extra, + }) + if err != nil { + t.Fatal(err) + } + first, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{ + DeviceID: "ec20-1", IMSI: "23415", Peer: "+447700900123", Source: "ims", + MessageReference: 42, StatusCode: 0, DeliveryState: "delivered", + }) + if err != nil || first.ID != sent.ID || first.DeliveryState != "pending_delivery_report" { + t.Fatalf("first delivery report = (%#v, %v)", first, err) + } + second, err := database.ApplySMSDeliveryReport(ctx, SMSDeliveryReport{ + DeviceID: "ec20-1", IMSI: "23415", Peer: "+447700900123", Source: "ims", + MessageReference: 43, StatusCode: 0, DeliveryState: "delivered", + }) + if err != nil || second.ID != sent.ID || second.DeliveryState != "delivered" { + t.Fatalf("second delivery report = (%#v, %v)", second, err) + } + var savedExtra map[string]any + if err := json.Unmarshal(second.Extra, &savedExtra); err != nil { + t.Fatal(err) + } + reports, _ := savedExtra["delivery_reports"].(map[string]any) + if len(reports) != 2 { + t.Fatalf("delivery reports = %#v", reports) + } +} + +func TestProxyCredentialsAndCountryRules(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + mustSaveDevice(t, database, "ec20-1", "EC20") + if err := database.UpsertLocalProxy(ctx, LocalProxyConfig{ + ID: "local-1", Name: "SOCKS", Mode: "socks5", DeviceID: "ec20-1", + ListenAddr: "127.0.0.1", ListenPort: 1080, Enabled: true, + AuthEnabled: true, Username: "user", Password: "local-secret", + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertLocalProxy(ctx, LocalProxyConfig{ + ID: "local-1", Name: "SOCKS 新", Mode: "socks5", DeviceID: "ec20-1", + ListenAddr: "127.0.0.1", ListenPort: 1080, Enabled: true, + AuthEnabled: true, Username: "user", Password: "", + }); err != nil { + t.Fatal(err) + } + local, err := database.LocalProxy(ctx, "local-1") + if err != nil { + t.Fatal(err) + } + if local.Password != "local-secret" || local.Redacted().Password != SecretMask || + local.Public().Password != "" { + t.Fatalf("local proxy credential semantics failed: %+v", local) + } + + if err := database.UpsertUpstreamProxy(ctx, UpstreamProxy{ + ID: "up-1", Name: "上游", Addr: "127.0.0.1:2080", + Username: "up-user", Password: "up-secret", Enabled: true, + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertUpstreamProxy(ctx, UpstreamProxy{ + ID: "up-1", Name: "上游新", Addr: "127.0.0.1:2080", + Username: "up-user", Password: SecretMask, Enabled: true, + }); err != nil { + t.Fatal(err) + } + upstream, err := database.UpstreamProxy(ctx, "up-1") + if err != nil { + t.Fatal(err) + } + if upstream.Password != "up-secret" { + t.Fatalf("blank/masked update erased upstream secret: %+v", upstream) + } + if got := RedactText( + "connect local-secret through up-secret", + local, + upstream, + ); strings.Contains(got, "secret") { + t.Fatalf("RedactText leaked credentials: %q", got) + } + if err := database.UpsertCountryRule(ctx, CountryRule{ + CountryCode: "cn", CountryName: "中国", UpstreamProxyID: "up-1", + Enabled: true, + }); err != nil { + t.Fatal(err) + } + rule, err := database.CountryRule(ctx, "CN") + if err != nil || rule.CountryCode != "CN" { + t.Fatalf("CountryRule() = %+v, %v", rule, err) + } + if err := database.UpsertDeviceProxyBinding(ctx, DeviceProxyBinding{ + DeviceID: "ec20-1", UpstreamProxyID: "up-1", + }); err != nil { + t.Fatal(err) + } + binding, err := database.DeviceProxyBinding(ctx, "ec20-1") + if err != nil || binding.UpstreamProxyID != "up-1" { + t.Fatalf("DeviceProxyBinding() = %+v, %v", binding, err) + } + if err := database.DeleteUpstreamProxy(ctx, "up-1"); err != nil { + t.Fatal(err) + } + if _, err := database.CountryRule(ctx, "CN"); !errors.Is(err, ErrNotFound) { + t.Fatalf("country rule should cascade with upstream deletion, got %v", err) + } + if _, err := database.DeviceProxyBinding(ctx, "ec20-1"); !errors.Is(err, ErrNotFound) { + t.Fatalf("device binding should cascade with upstream deletion, got %v", err) + } +} + +func TestNotificationAndAppSecretPreservation(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + if err := database.SaveNotificationSettings(ctx, []NotificationSetting{ + { + Channel: "email", + Config: json.RawMessage(`{"password":"mail-secret"}`), + }, + { + Channel: "webhook", + Config: json.RawMessage(`not-json`), + }, + }); err == nil { + t.Fatal("invalid notification batch was accepted") + } + if _, err := database.NotificationSetting(ctx, "email"); !errors.Is(err, ErrNotFound) { + t.Fatalf("notification batch was not rolled back: %v", err) + } + if err := database.UpsertNotificationSetting(ctx, NotificationSetting{ + Channel: "telegram", + Enabled: true, + Config: json.RawMessage(`{"bot_token":"telegram-secret","chat_id":"1"}`), + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertNotificationSetting(ctx, NotificationSetting{ + Channel: "telegram", + Enabled: true, + Config: json.RawMessage(`{"bot_token":"","chat_id":"2"}`), + }); err != nil { + t.Fatal(err) + } + setting, err := database.NotificationSetting(ctx, "telegram") + if err != nil { + t.Fatal(err) + } + var config map[string]any + if err := json.Unmarshal(setting.Config, &config); err != nil { + t.Fatal(err) + } + if config["bot_token"] != "telegram-secret" || config["chat_id"] != "2" { + t.Fatalf("notification merge lost data: %s", setting.Config) + } + if bytes.Contains(setting.Redacted().Config, []byte("telegram-secret")) || + bytes.Contains(setting.Public().Config, []byte("telegram-secret")) { + t.Fatal("notification views leaked secret") + } + if got := RedactText("token=telegram-secret", setting); strings.Contains(got, "telegram-secret") { + t.Fatalf("notification secret leaked in text: %q", got) + } + + if err := database.UpsertAppSetting(ctx, AppSetting{ + Key: "provider.token", Value: json.RawMessage(`"app-secret"`), Sensitive: true, + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertAppSetting(ctx, AppSetting{ + Key: "provider.token", Value: json.RawMessage(`"********"`), Sensitive: true, + }); err != nil { + t.Fatal(err) + } + appSetting, err := database.AppSetting(ctx, "provider.token") + if err != nil { + t.Fatal(err) + } + if string(appSetting.Value) != `"app-secret"` || + string(appSetting.Redacted().Value) != `"********"` || + string(appSetting.Public().Value) != `null` { + t.Fatalf("unexpected sensitive app setting: %+v", appSetting) + } +} + +func TestEventsPoliciesAndTraffic(t *testing.T) { + ctx := context.Background() + database := openTestStore(t, ":memory:") + old := time.Unix(1_700_000_000, 0).UTC() + recent := old.Add(time.Hour) + if _, err := database.AppendAuditEvent(ctx, AuditEvent{ + Actor: "admin", Action: "device.update", EntityType: "device", + EntityID: "ec20-1", Outcome: "ok", CreatedAt: old, + }); err != nil { + t.Fatal(err) + } + if _, err := database.AppendAuditEvent(ctx, AuditEvent{ + Actor: "system", Action: "device.refresh", EntityType: "device", + EntityID: "ec20-1", Outcome: "ok", CreatedAt: recent, + }); err != nil { + t.Fatal(err) + } + audits, err := database.ListAuditEvents(ctx, AuditFilter{Actor: "admin"}) + if err != nil || len(audits) != 1 || audits[0].Action != "device.update" { + t.Fatalf("audit filter result = %+v, %v", audits, err) + } + if _, err := database.AppendLogEvent(ctx, LogEvent{ + Time: old, Level: "warn", Message: "old warning", + Fields: json.RawMessage(`{"device":"ec20-1"}`), + }); err != nil { + t.Fatal(err) + } + if _, err := database.AppendLogEvent(ctx, LogEvent{ + Time: recent, Level: "info", Message: "ready", + }); err != nil { + t.Fatal(err) + } + logs, err := database.ListLogEvents(ctx, LogFilter{Level: "info"}) + if err != nil || len(logs) != 1 || logs[0].Message != "ready" { + t.Fatalf("log filter result = %+v, %v", logs, err) + } + auditDeleted, logDeleted, err := database.PruneEvents( + ctx, + old.Add(time.Minute), + old.Add(time.Minute), + ) + if err != nil || auditDeleted != 1 || logDeleted != 1 { + t.Fatalf("PruneEvents() = %d, %d, %v", auditDeleted, logDeleted, err) + } + + if err := database.UpsertCardPolicy(ctx, CardPolicy{ + ICCID: "89860001", NetworkEnabled: true, VoWiFiEnabled: true, + APN: "ims", IPVersion: "ipv4v6", + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertCardPolicy(ctx, CardPolicy{ + ICCID: "invalid", VoWiFiEnabled: true, AirplaneEnabled: true, + }); err == nil { + t.Fatal("invalid mutually exclusive card policy was accepted") + } + policy, err := database.CardPolicy(ctx, "89860001") + if err != nil || !policy.VoWiFiEnabled { + t.Fatalf("CardPolicy() = %+v, %v", policy, err) + } + + period := old.Truncate(time.Hour) + if err := database.UpsertTrafficBucket(ctx, TrafficBucket{ + DeviceID: "ec20-1", Bucket: "hour", PeriodStart: period, + RXBytes: 100, TXBytes: 25, + }); err != nil { + t.Fatal(err) + } + if err := database.AddTrafficBucket(ctx, TrafficBucket{ + DeviceID: "ec20-1", Bucket: "hour", PeriodStart: period, + RXBytes: 5, TXBytes: 10, + }); err != nil { + t.Fatal(err) + } + buckets, err := database.ListTrafficBuckets(ctx, TrafficFilter{ + DeviceID: "ec20-1", Bucket: "hour", + }) + if err != nil || len(buckets) != 1 || + buckets[0].RXBytes != 105 || buckets[0].TXBytes != 35 || + buckets[0].TotalBytes() != 140 { + t.Fatalf("traffic buckets = %+v, %v", buckets, err) + } +} + +func openTestStore(t *testing.T, path string) *Store { + t.Helper() + database, err := Open(context.Background(), path) + if err != nil { + t.Fatalf("Open(%q) error = %v", path, err) + } + t.Cleanup(func() { + if err := database.Close(); err != nil { + t.Errorf("Close() error = %v", err) + } + }) + return database +} + +func mustSaveDevice(t *testing.T, database *Store, id, name string) { + t.Helper() + if err := database.UpsertDevice(context.Background(), Device{ + ID: id, Name: name, SMSEnabled: true, + }); err != nil { + t.Fatalf("UpsertDevice() error = %v", err) + } +} diff --git a/internal/store/events.go b/internal/store/events.go new file mode 100644 index 0000000..bfcf9a7 --- /dev/null +++ b/internal/store/events.go @@ -0,0 +1,306 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +func (s *Store) AppendAuditEvent(ctx context.Context, value AuditEvent) (AuditEvent, error) { + value.Action = strings.TrimSpace(value.Action) + if value.Action == "" { + return AuditEvent{}, errors.New("audit action is required") + } + details, err := normalizeJSONObject(value.Details) + if err != nil { + return AuditEvent{}, fmt.Errorf("normalize audit details: %w", err) + } + if value.CreatedAt.IsZero() { + value.CreatedAt = time.Now().UTC() + } + result, err := s.db.ExecContext(ctx, ` + INSERT INTO audit_events ( + actor, action, entity_type, entity_id, outcome, remote_addr, + details_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + value.Actor, value.Action, value.EntityType, value.EntityID, + value.Outcome, value.RemoteAddr, string(details), value.CreatedAt.Unix(), + ) + if err != nil { + return AuditEvent{}, fmt.Errorf("append audit event: %w", err) + } + value.ID, err = result.LastInsertId() + if err != nil { + return AuditEvent{}, fmt.Errorf("read audit event id: %w", err) + } + value.Details = details + return value, nil +} + +func (s *Store) ListAuditEvents(ctx context.Context, filter AuditFilter) ([]AuditEvent, error) { + clauses := make([]string, 0, 7) + args := make([]any, 0, 8) + if filter.Actor != "" { + clauses = append(clauses, `actor = ?`) + args = append(args, filter.Actor) + } + if filter.Action != "" { + clauses = append(clauses, `action = ?`) + args = append(args, filter.Action) + } + if filter.EntityType != "" { + clauses = append(clauses, `entity_type = ?`) + args = append(args, filter.EntityType) + } + if filter.EntityID != "" { + clauses = append(clauses, `entity_id = ?`) + args = append(args, filter.EntityID) + } + if !filter.Since.IsZero() { + clauses = append(clauses, `created_at >= ?`) + args = append(args, filter.Since.UTC().Unix()) + } + if !filter.Until.IsZero() { + clauses = append(clauses, `created_at < ?`) + args = append(args, filter.Until.UTC().Unix()) + } + if filter.BeforeID > 0 { + clauses = append(clauses, `id < ?`) + args = append(args, filter.BeforeID) + } + query := auditEventSelect + if len(clauses) > 0 { + query += ` WHERE ` + strings.Join(clauses, ` AND `) + } + query += ` ORDER BY created_at DESC, id DESC LIMIT ?` + args = append(args, normalizedLimit(filter.Limit)) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list audit events: %w", err) + } + defer rows.Close() + values := make([]AuditEvent, 0) + for rows.Next() { + value, err := auditEvent(rows) + if err != nil { + return nil, fmt.Errorf("scan audit event: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate audit events: %w", err) + } + return values, nil +} + +const auditEventSelect = ` + SELECT id, actor, action, entity_type, entity_id, outcome, + remote_addr, details_json, created_at + FROM audit_events` + +func auditEvent(row rowScanner) (AuditEvent, error) { + var value AuditEvent + var details string + var createdAt int64 + err := row.Scan( + &value.ID, &value.Actor, &value.Action, &value.EntityType, + &value.EntityID, &value.Outcome, &value.RemoteAddr, &details, + &createdAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return AuditEvent{}, ErrNotFound + } + if err != nil { + return AuditEvent{}, err + } + value.Details = []byte(details) + value.CreatedAt = time.Unix(createdAt, 0).UTC() + return value, nil +} + +func (s *Store) AppendLogEvent(ctx context.Context, value LogEvent) (LogEvent, error) { + value.Level = strings.ToLower(strings.TrimSpace(value.Level)) + if value.Level == "" { + return LogEvent{}, errors.New("log level is required") + } + if strings.TrimSpace(value.Message) == "" { + return LogEvent{}, errors.New("log message is required") + } + fields, err := normalizeJSONValue(value.Fields) + if err != nil { + return LogEvent{}, fmt.Errorf("normalize log fields: %w", err) + } + if value.Time.IsZero() { + value.Time = time.Now().UTC() + } + result, err := s.db.ExecContext(ctx, ` + INSERT INTO log_events (event_time, level, message, caller, fields_json) + VALUES (?, ?, ?, ?, ?) + `, value.Time.Unix(), value.Level, value.Message, value.Caller, string(fields)) + if err != nil { + return LogEvent{}, fmt.Errorf("append log event: %w", err) + } + value.ID, err = result.LastInsertId() + if err != nil { + return LogEvent{}, fmt.Errorf("read log event id: %w", err) + } + value.Fields = fields + return value, nil +} + +func (s *Store) ListLogEvents(ctx context.Context, filter LogFilter) ([]LogEvent, error) { + clauses := make([]string, 0, 4) + args := make([]any, 0, 5) + if filter.Level != "" { + clauses = append(clauses, `level = ?`) + args = append(args, strings.ToLower(filter.Level)) + } + if !filter.Since.IsZero() { + clauses = append(clauses, `event_time >= ?`) + args = append(args, filter.Since.UTC().Unix()) + } + if !filter.Until.IsZero() { + clauses = append(clauses, `event_time < ?`) + args = append(args, filter.Until.UTC().Unix()) + } + if filter.BeforeID > 0 { + clauses = append(clauses, `id < ?`) + args = append(args, filter.BeforeID) + } + query := logEventSelect + if len(clauses) > 0 { + query += ` WHERE ` + strings.Join(clauses, ` AND `) + } + query += ` ORDER BY event_time DESC, id DESC LIMIT ?` + args = append(args, normalizedLimit(filter.Limit)) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list log events: %w", err) + } + defer rows.Close() + values := make([]LogEvent, 0) + for rows.Next() { + value, err := logEvent(rows) + if err != nil { + return nil, fmt.Errorf("scan log event: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate log events: %w", err) + } + return values, nil +} + +const logEventSelect = ` + SELECT id, event_time, level, message, caller, fields_json + FROM log_events` + +func logEvent(row rowScanner) (LogEvent, error) { + var value LogEvent + var eventTime int64 + var fields string + err := row.Scan( + &value.ID, &eventTime, &value.Level, &value.Message, + &value.Caller, &fields, + ) + if errors.Is(err, sql.ErrNoRows) { + return LogEvent{}, ErrNotFound + } + if err != nil { + return LogEvent{}, err + } + value.Time = time.Unix(eventTime, 0).UTC() + value.Fields = []byte(fields) + return value, nil +} + +func (s *Store) PruneAuditEvents(ctx context.Context, before time.Time) (int64, error) { + return deleteEventsBefore(ctx, s.db, `audit_events`, `created_at`, before) +} + +func (s *Store) PruneLogEvents(ctx context.Context, before time.Time) (int64, error) { + return deleteEventsBefore(ctx, s.db, `log_events`, `event_time`, before) +} + +// CountLogEvents returns how many application log rows are persisted. +func (s *Store) CountLogEvents(ctx context.Context) (int64, error) { + var count int64 + if err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM log_events`).Scan(&count); err != nil { + return 0, fmt.Errorf("count log events: %w", err) + } + return count, nil +} + +// PruneLogEventsToCount keeps only the newest `keep` log rows, deleting the +// rest. keep <= 0 deletes everything. +func (s *Store) PruneLogEventsToCount(ctx context.Context, keep int) (int64, error) { + if keep < 0 { + keep = 0 + } + result, err := s.db.ExecContext(ctx, ` + DELETE FROM log_events WHERE id NOT IN ( + SELECT id FROM log_events ORDER BY id DESC LIMIT ? + ) + `, keep) + if err != nil { + return 0, fmt.Errorf("prune log events to count: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("read pruned log count: %w", err) + } + return affected, nil +} + +func deleteEventsBefore( + ctx context.Context, + executor contextExecer, + table string, + column string, + before time.Time, +) (int64, error) { + // table and column are internal constants from the callers above. + result, err := executor.ExecContext( + ctx, + `DELETE FROM `+table+` WHERE `+column+` < ?`, + before.UTC().Unix(), + ) + if err != nil { + return 0, fmt.Errorf("prune %s: %w", table, err) + } + affected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("read pruned %s count: %w", table, err) + } + return affected, nil +} + +// PruneEvents removes audit and application logs atomically. +func (s *Store) PruneEvents( + ctx context.Context, + auditBefore time.Time, + logBefore time.Time, +) (auditCount int64, logCount int64, err error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return 0, 0, fmt.Errorf("begin event pruning: %w", err) + } + defer tx.Rollback() + auditCount, err = deleteEventsBefore(ctx, tx, `audit_events`, `created_at`, auditBefore) + if err != nil { + return 0, 0, err + } + logCount, err = deleteEventsBefore(ctx, tx, `log_events`, `event_time`, logBefore) + if err != nil { + return 0, 0, err + } + if err := tx.Commit(); err != nil { + return 0, 0, fmt.Errorf("commit event pruning: %w", err) + } + return auditCount, logCount, nil +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go new file mode 100644 index 0000000..438de3c --- /dev/null +++ b/internal/store/migrations.go @@ -0,0 +1,325 @@ +package store + +func migrationStatements(version int) []string { + switch version { + case 1: + return []string{ + `CREATE TABLE IF NOT EXISTS admins ( + id INTEGER PRIMARY KEY CHECK (id = 1), + username TEXT NOT NULL UNIQUE, + password_hash BLOB NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE TABLE IF NOT EXISTS sessions ( + token_hash BLOB PRIMARY KEY, + admin_id INTEGER NOT NULL, + csrf_hash BLOB NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (admin_id) REFERENCES admins(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at)`, + } + case 2: + return domainSchemaV2 + case 3: + return []string{ + `CREATE TABLE phone_associations ( + iccid TEXT PRIMARY KEY, + device_id TEXT NOT NULL DEFAULT '', + number TEXT NOT NULL, + source TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE INDEX phone_associations_device_idx + ON phone_associations(device_id, updated_at DESC)`, + } + case 4: + return []string{ + // The conversation UI is a received-message view. Keep the SMSC + // timestamp for diagnostics, but display the first local receipt time. + `UPDATE sms_messages + SET extra_json = json_set( + extra_json, + '$.service_center_timestamp_unix', message_time, + '$.received_at_unix', created_at + ), + message_time = created_at, + updated_at = CAST(strftime('%s', 'now') AS INTEGER) + WHERE source = 'ims' + AND json_valid(extra_json) + AND COALESCE(json_extract(extra_json, '$.raw_tpdu'), '') <> ''`, + } + case 5: + return []string{ + // Earlier IMS persistence labelled every non-delivered submission as + // accepted_by_ims, including explicit SIP/RP rejection evidence. + `UPDATE sms_messages + SET delivery_state = 'failed', + updated_at = CAST(strftime('%s', 'now') AS INTEGER) + WHERE source = 'ims' + AND direction IN ('outbound', 'sent') + AND delivery_state = 'accepted_by_ims' + AND ( + LOWER(status) LIKE '%reject%' + OR LOWER(status) LIKE '%fail%' + OR LOWER(status) LIKE '%partial%' + )`, + } + case 6: + return []string{ + `CREATE TABLE IF NOT EXISTS device_proxy_bindings ( + device_id TEXT PRIMARY KEY, + upstream_proxy_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE, + FOREIGN KEY (upstream_proxy_id) REFERENCES upstream_proxies(id) ON DELETE CASCADE + )`, + `CREATE INDEX IF NOT EXISTS device_proxy_bindings_proxy_idx + ON device_proxy_bindings(upstream_proxy_id)`, + } + default: + return nil + } +} + +var domainSchemaV2 = []string{ + `CREATE TABLE devices ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + interface TEXT NOT NULL DEFAULT '', + control_device TEXT NOT NULL DEFAULT '', + at_port TEXT NOT NULL DEFAULT '', + usb_path TEXT NOT NULL DEFAULT '', + audio_device TEXT NOT NULL DEFAULT '', + modem_imei TEXT NOT NULL DEFAULT '', + apn TEXT NOT NULL DEFAULT '', + proxy_port INTEGER NOT NULL DEFAULT 0 CHECK (proxy_port BETWEEN 0 AND 65535), + baud_rate INTEGER NOT NULL DEFAULT 115200 CHECK (baud_rate > 0), + data_bits INTEGER NOT NULL DEFAULT 8, + stop_bits INTEGER NOT NULL DEFAULT 1, + parity TEXT NOT NULL DEFAULT 'none', + device_backend TEXT NOT NULL DEFAULT 'at', + esim_transport TEXT NOT NULL DEFAULT 'at', + qmi_use_proxy INTEGER NOT NULL DEFAULT 1 CHECK (qmi_use_proxy IN (0, 1)), + qmi_proxy_path TEXT NOT NULL DEFAULT '', + qmi_proxy_executable TEXT NOT NULL DEFAULT '', + network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)), + sms_enabled INTEGER NOT NULL DEFAULT 1 CHECK (sms_enabled IN (0, 1)), + vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)), + extra_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE INDEX devices_interface_idx ON devices(interface)`, + `CREATE INDEX devices_imei_idx ON devices(modem_imei)`, + + `CREATE TABLE device_runtime ( + device_id TEXT PRIMARY KEY, + running INTEGER NOT NULL DEFAULT 0 CHECK (running IN (0, 1)), + healthy INTEGER NOT NULL DEFAULT 0 CHECK (healthy IN (0, 1)), + control_online INTEGER NOT NULL DEFAULT 0 CHECK (control_online IN (0, 1)), + physical_present INTEGER NOT NULL DEFAULT 0 CHECK (physical_present IN (0, 1)), + worker_running INTEGER NOT NULL DEFAULT 0 CHECK (worker_running IN (0, 1)), + data_connected INTEGER NOT NULL DEFAULT 0 CHECK (data_connected IN (0, 1)), + radio_registered INTEGER NOT NULL DEFAULT 0 CHECK (radio_registered IN (0, 1)), + network_connected INTEGER NOT NULL DEFAULT 0 CHECK (network_connected IN (0, 1)), + flight_mode INTEGER NOT NULL DEFAULT 0 CHECK (flight_mode IN (0, 1)), + lifecycle_phase TEXT NOT NULL DEFAULT '', + lifecycle_reason TEXT NOT NULL DEFAULT '', + public_ip TEXT NOT NULL DEFAULT '', + private_ip TEXT NOT NULL DEFAULT '', + operator TEXT NOT NULL DEFAULT '', + native_mcc TEXT NOT NULL DEFAULT '', + native_mnc TEXT NOT NULL DEFAULT '', + native_spn TEXT NOT NULL DEFAULT '', + network_mode TEXT NOT NULL DEFAULT '', + network_duplex TEXT NOT NULL DEFAULT '', + radio_band TEXT NOT NULL DEFAULT '', + radio_channel INTEGER NOT NULL DEFAULT 0, + signal_dbm INTEGER NOT NULL DEFAULT 0, + signal_rsrp INTEGER, + signal_rsrq INTEGER, + signal_sinr INTEGER, + imei TEXT NOT NULL DEFAULT '', + iccid TEXT NOT NULL DEFAULT '', + imsi TEXT NOT NULL DEFAULT '', + firmware TEXT NOT NULL DEFAULT '', + reg_status INTEGER NOT NULL DEFAULT 0, + reg_status_text TEXT NOT NULL DEFAULT '', + ps_attached INTEGER CHECK (ps_attached IN (0, 1)), + sim_inserted INTEGER CHECK (sim_inserted IN (0, 1)), + operating_mode INTEGER, + phone_number TEXT NOT NULL DEFAULT '', + phone_number_source TEXT NOT NULL DEFAULT '', + traffic_json TEXT NOT NULL DEFAULT '{}', + extra_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE + )`, + `CREATE INDEX device_runtime_iccid_idx ON device_runtime(iccid)`, + `CREATE INDEX device_runtime_imsi_idx ON device_runtime(imsi)`, + + `CREATE TABLE vowifi_runtime ( + device_id TEXT PRIMARY KEY, + phase TEXT NOT NULL DEFAULT '', + dataplane_mode TEXT NOT NULL DEFAULT '', + iccid TEXT NOT NULL DEFAULT '', + imsi TEXT NOT NULL DEFAULT '', + sim_ready INTEGER NOT NULL DEFAULT 0 CHECK (sim_ready IN (0, 1)), + access_ready INTEGER NOT NULL DEFAULT 0 CHECK (access_ready IN (0, 1)), + tunnel_ready INTEGER NOT NULL DEFAULT 0 CHECK (tunnel_ready IN (0, 1)), + ims_ready INTEGER NOT NULL DEFAULT 0 CHECK (ims_ready IN (0, 1)), + sms_ready INTEGER NOT NULL DEFAULT 0 CHECK (sms_ready IN (0, 1)), + reg_status INTEGER NOT NULL DEFAULT 0, + reg_status_text TEXT NOT NULL DEFAULT '', + network_mode TEXT NOT NULL DEFAULT '', + local_phone TEXT NOT NULL DEFAULT '', + phone_number_source TEXT NOT NULL DEFAULT '', + last_error_class TEXT NOT NULL DEFAULT '', + last_error TEXT NOT NULL DEFAULT '', + last_reason TEXT NOT NULL DEFAULT '', + tunnel_json TEXT NOT NULL DEFAULT '{}', + imscore_json TEXT NOT NULL DEFAULT '{}', + smsip_json TEXT NOT NULL DEFAULT '{}', + extra_json TEXT NOT NULL DEFAULT '{}', + updated_at INTEGER NOT NULL, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE + )`, + `CREATE INDEX vowifi_runtime_ims_ready_idx ON vowifi_runtime(ims_ready)`, + + `CREATE TABLE sms_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT NOT NULL DEFAULT '', + device_id TEXT NOT NULL, + imsi TEXT NOT NULL DEFAULT '', + peer TEXT NOT NULL, + direction TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + message_time INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + parts_total INTEGER NOT NULL DEFAULT 1 CHECK (parts_total > 0), + delivery_state TEXT NOT NULL DEFAULT '', + is_read INTEGER NOT NULL DEFAULT 0 CHECK (is_read IN (0, 1)), + extra_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `CREATE UNIQUE INDEX sms_messages_external_id_idx + ON sms_messages(device_id, message_id) + WHERE message_id <> ''`, + `CREATE INDEX sms_messages_thread_idx + ON sms_messages(device_id, imsi, peer, message_time DESC, id DESC)`, + `CREATE INDEX sms_messages_time_idx ON sms_messages(message_time DESC)`, + + `CREATE TABLE local_proxy_config ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + mode TEXT NOT NULL CHECK (mode IN ('socks5', 'http')), + device_id TEXT NOT NULL, + listen_addr TEXT NOT NULL, + listen_port INTEGER NOT NULL CHECK (listen_port BETWEEN 1 AND 65535), + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + auth_enabled INTEGER NOT NULL DEFAULT 0 CHECK (auth_enabled IN (0, 1)), + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + extra_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (device_id) REFERENCES devices(id) ON DELETE CASCADE + )`, + `CREATE INDEX local_proxy_device_idx ON local_proxy_config(device_id)`, + + `CREATE TABLE upstream_proxies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + addr TEXT NOT NULL, + username TEXT NOT NULL DEFAULT '', + password TEXT NOT NULL DEFAULT '', + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + extra_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + + `CREATE TABLE country_rules ( + country_code TEXT PRIMARY KEY, + country_name TEXT NOT NULL DEFAULT '', + upstream_proxy_id TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), + extra_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (upstream_proxy_id) REFERENCES upstream_proxies(id) ON DELETE CASCADE + )`, + `CREATE INDEX country_rules_proxy_idx ON country_rules(upstream_proxy_id)`, + + `CREATE TABLE notification_settings ( + channel TEXT PRIMARY KEY, + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + config_json TEXT NOT NULL DEFAULT '{}', + sensitive_fields_json TEXT NOT NULL DEFAULT '[]', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + + `CREATE TABLE app_settings ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL, + sensitive INTEGER NOT NULL DEFAULT 0 CHECK (sensitive IN (0, 1)), + updated_at INTEGER NOT NULL + )`, + + `CREATE TABLE audit_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor TEXT NOT NULL DEFAULT '', + action TEXT NOT NULL, + entity_type TEXT NOT NULL DEFAULT '', + entity_id TEXT NOT NULL DEFAULT '', + outcome TEXT NOT NULL DEFAULT '', + remote_addr TEXT NOT NULL DEFAULT '', + details_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL + )`, + `CREATE INDEX audit_events_created_idx ON audit_events(created_at DESC, id DESC)`, + `CREATE INDEX audit_events_entity_idx ON audit_events(entity_type, entity_id, created_at DESC)`, + + `CREATE TABLE log_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_time INTEGER NOT NULL, + level TEXT NOT NULL, + message TEXT NOT NULL, + caller TEXT NOT NULL DEFAULT '', + fields_json TEXT NOT NULL DEFAULT '{}' + )`, + `CREATE INDEX log_events_time_idx ON log_events(event_time DESC, id DESC)`, + `CREATE INDEX log_events_level_idx ON log_events(level, event_time DESC)`, + + `CREATE TABLE card_policies ( + iccid TEXT PRIMARY KEY, + network_enabled INTEGER NOT NULL DEFAULT 0 CHECK (network_enabled IN (0, 1)), + vowifi_enabled INTEGER NOT NULL DEFAULT 0 CHECK (vowifi_enabled IN (0, 1)), + airplane_enabled INTEGER NOT NULL DEFAULT 0 CHECK (airplane_enabled IN (0, 1)), + apn TEXT NOT NULL DEFAULT '', + ip_version TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + CHECK (NOT (vowifi_enabled = 1 AND airplane_enabled = 1)) + )`, + + `CREATE TABLE traffic_buckets ( + device_id TEXT NOT NULL, + bucket TEXT NOT NULL, + period_start INTEGER NOT NULL, + rx_bytes INTEGER NOT NULL DEFAULT 0 CHECK (rx_bytes >= 0), + tx_bytes INTEGER NOT NULL DEFAULT 0 CHECK (tx_bytes >= 0), + PRIMARY KEY (device_id, bucket, period_start) + )`, + `CREATE INDEX traffic_buckets_period_idx + ON traffic_buckets(bucket, period_start DESC)`, +} diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 0000000..f7aa33d --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,608 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "reflect" + "sort" + "strconv" + "strings" + "time" +) + +const SecretMask = "********" + +type Device struct { + ID string + Name string + Interface string + ControlDevice string + ATPort string + USBPath string + AudioDevice string + ModemIMEI string + APN string + ProxyPort int + BaudRate int + DataBits int + StopBits int + Parity string + DeviceBackend string + ESIMTransport string + QMIUseProxy bool + QMIProxyPath string + QMIProxyExecutable string + NetworkEnabled bool + SMSEnabled bool + VoWiFiEnabled bool + Extra json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +type DeviceRuntime struct { + DeviceID string + Running bool + Healthy bool + ControlOnline bool + PhysicalPresent bool + WorkerRunning bool + DataConnected bool + RadioRegistered bool + NetworkConnected bool + FlightMode bool + LifecyclePhase string + LifecycleReason string + PublicIP string + PrivateIP string + Operator string + NativeMCC string + NativeMNC string + NativeSPN string + NetworkMode string + NetworkDuplex string + RadioBand string + RadioChannel int + SignalDBM int + SignalRSRP *int + SignalRSRQ *int + SignalSINR *int + IMEI string + ICCID string + IMSI string + Firmware string + RegStatus int + RegStatusText string + PSAttached *bool + SIMInserted *bool + OperatingMode *int + PhoneNumber string + PhoneNumberSource string + Traffic json.RawMessage + Extra json.RawMessage + UpdatedAt time.Time +} + +type VoWiFiRuntime struct { + DeviceID string + Phase string + DataplaneMode string + ICCID string + IMSI string + SIMReady bool + AccessReady bool + TunnelReady bool + IMSReady bool + SMSReady bool + RegStatus int + RegStatusText string + NetworkMode string + LocalPhone string + PhoneNumberSource string + LastErrorClass string + LastError string + LastReason string + Tunnel json.RawMessage + IMSCore json.RawMessage + SMSIP json.RawMessage + Extra json.RawMessage + UpdatedAt time.Time +} + +// PhoneAssociation is a number explicitly published by IMS for one SIM. It is +// keyed by ICCID so a verified number survives service restarts and follows the +// SIM without ever being inferred from IMSI. +type PhoneAssociation struct { + ICCID string + DeviceID string + Number string + Source string + CreatedAt time.Time + UpdatedAt time.Time +} + +type SMSMessage struct { + ID int64 + MessageID string + DeviceID string + IMSI string + Peer string + Direction string + Body string + Timestamp time.Time + Status string + Source string + PartsTotal int + DeliveryState string + Read bool + Extra json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +type SMSFilter struct { + DeviceID string + IMSI string + Peer string + Since time.Time + Until time.Time + BeforeID int64 + Limit int +} + +// SMSDeliveryReport is network evidence for one submitted SMS part. The +// message reference is the TP-MR returned in SMS-STATUS-REPORT. +type SMSDeliveryReport struct { + DeviceID string + IMSI string + Peer string + Source string + MessageReference int + StatusCode int + DeliveryState string + ServiceCenterTime *time.Time + DischargeTime *time.Time + ReceivedAt time.Time +} + +type SMSContact struct { + DeviceID string + DeviceName string + IMSI string + LocalPhone string + Peer string + DisplayName string + LastMessage string + LastTimestamp time.Time + Direction string + LastSMSID int64 + UnreadCount int + MessageCount int +} + +type LocalProxyConfig struct { + ID string + Name string + Mode string + DeviceID string + ListenAddr string + ListenPort int + Enabled bool + AuthEnabled bool + Username string + Password string + Extra json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +func (value LocalProxyConfig) Redacted() LocalProxyConfig { + if value.Password != "" { + value.Password = SecretMask + } + return value +} + +func (value LocalProxyConfig) Public() LocalProxyConfig { + value.Password = "" + return value +} + +func (value LocalProxyConfig) SensitiveValues() []string { + if value.Password == "" || value.Password == SecretMask { + return nil + } + return []string{value.Password} +} + +type UpstreamProxy struct { + ID string + Name string + Addr string + Username string + Password string + Enabled bool + Extra json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +func (value UpstreamProxy) Redacted() UpstreamProxy { + if value.Password != "" { + value.Password = SecretMask + } + return value +} + +func (value UpstreamProxy) Public() UpstreamProxy { + value.Password = "" + return value +} + +func (value UpstreamProxy) SensitiveValues() []string { + if value.Password == "" || value.Password == SecretMask { + return nil + } + return []string{value.Password} +} + +type CountryRule struct { + CountryCode string + CountryName string + UpstreamProxyID string + Enabled bool + Extra json.RawMessage + CreatedAt time.Time + UpdatedAt time.Time +} + +// DeviceProxyBinding selects the SOCKS5 upstream used by one device's whole +// VoWiFi runtime. The IKE/IPsec transport uses this route and IMS/SMS then +// travel inside that tunnel. +type DeviceProxyBinding struct { + DeviceID string + UpstreamProxyID string + CreatedAt time.Time + UpdatedAt time.Time +} + +type NotificationSetting struct { + Channel string + Enabled bool + Config json.RawMessage + SensitiveFields []string + CreatedAt time.Time + UpdatedAt time.Time +} + +func (value NotificationSetting) Redacted() NotificationSetting { + value.Config = redactJSONFields(value.Config, value.SensitiveFields, SecretMask) + return value +} + +func (value NotificationSetting) Public() NotificationSetting { + value.Config = redactJSONFields(value.Config, value.SensitiveFields, "") + return value +} + +func (value NotificationSetting) SensitiveValues() []string { + document, err := decodeJSONObject(value.Config) + if err != nil { + return nil + } + values := make([]string, 0, len(value.SensitiveFields)) + for _, field := range value.SensitiveFields { + if secret, ok := getJSONPath(document, field).(string); ok && + secret != "" && secret != SecretMask { + values = append(values, secret) + } + } + return values +} + +type AppSetting struct { + Key string + Value json.RawMessage + Sensitive bool + UpdatedAt time.Time +} + +func (value AppSetting) Redacted() AppSetting { + if value.Sensitive { + value.Value = json.RawMessage(strconv.Quote(SecretMask)) + } + return value +} + +func (value AppSetting) Public() AppSetting { + if value.Sensitive { + value.Value = json.RawMessage(`null`) + } + return value +} + +func (value AppSetting) SensitiveValues() []string { + if !value.Sensitive { + return nil + } + var decoded any + decoder := json.NewDecoder(bytes.NewReader(value.Value)) + decoder.UseNumber() + if decoder.Decode(&decoded) != nil { + return nil + } + var values []string + collectJSONStringValues(decoded, &values) + return uniqueNonemptyStrings(values) +} + +type SensitiveValuesProvider interface { + SensitiveValues() []string +} + +func RedactText(text string, providers ...SensitiveValuesProvider) string { + var secrets []string + for _, provider := range providers { + if !nilSensitiveProvider(provider) { + secrets = append(secrets, provider.SensitiveValues()...) + } + } + sort.Slice(secrets, func(i, j int) bool { + return len(secrets[i]) > len(secrets[j]) + }) + for _, secret := range secrets { + if secret != "" && secret != SecretMask { + text = strings.ReplaceAll(text, secret, SecretMask) + } + } + return text +} + +func nilSensitiveProvider(provider SensitiveValuesProvider) bool { + if provider == nil { + return true + } + value := reflect.ValueOf(provider) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, + reflect.Pointer, reflect.Slice: + return value.IsNil() + default: + return false + } +} + +func collectJSONStringValues(value any, result *[]string) { + switch typed := value.(type) { + case string: + if typed != "" && typed != SecretMask { + *result = append(*result, typed) + } + case []any: + for _, item := range typed { + collectJSONStringValues(item, result) + } + case map[string]any: + for _, item := range typed { + collectJSONStringValues(item, result) + } + } +} + +type AuditEvent struct { + ID int64 + Actor string + Action string + EntityType string + EntityID string + Outcome string + RemoteAddr string + Details json.RawMessage + CreatedAt time.Time +} + +type AuditFilter struct { + Actor string + Action string + EntityType string + EntityID string + Since time.Time + Until time.Time + BeforeID int64 + Limit int +} + +type LogEvent struct { + ID int64 + Time time.Time + Level string + Message string + Caller string + Fields json.RawMessage +} + +type LogFilter struct { + Level string + Since time.Time + Until time.Time + BeforeID int64 + Limit int +} + +type CardPolicy struct { + ICCID string + NetworkEnabled bool + VoWiFiEnabled bool + AirplaneEnabled bool + APN string + IPVersion string + Source string + CreatedAt time.Time + UpdatedAt time.Time +} + +type TrafficBucket struct { + DeviceID string + Bucket string + PeriodStart time.Time + RXBytes int64 + TXBytes int64 +} + +func (value TrafficBucket) TotalBytes() int64 { + return value.RXBytes + value.TXBytes +} + +type TrafficFilter struct { + DeviceID string + Bucket string + Since time.Time + Until time.Time + Limit int +} + +func normalizeJSONObject(value json.RawMessage) (json.RawMessage, error) { + if len(bytes.TrimSpace(value)) == 0 { + return json.RawMessage(`{}`), nil + } + document, err := decodeJSONObject(value) + if err != nil { + return nil, err + } + normalized, err := json.Marshal(document) + if err != nil { + return nil, err + } + return normalized, nil +} + +func normalizeJSONValue(value json.RawMessage) (json.RawMessage, error) { + if len(bytes.TrimSpace(value)) == 0 { + return json.RawMessage(`null`), nil + } + if !json.Valid(value) { + return nil, errors.New("invalid JSON value") + } + return append(json.RawMessage(nil), value...), nil +} + +func decodeJSONObject(value json.RawMessage) (map[string]any, error) { + var document map[string]any + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.UseNumber() + if err := decoder.Decode(&document); err != nil { + return nil, err + } + var trailing any + if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("JSON value must contain exactly one object") + } + return nil, err + } + if document == nil { + return nil, errors.New("JSON value must be an object") + } + return document, nil +} + +func redactJSONFields(value json.RawMessage, fields []string, replacement string) json.RawMessage { + document, err := decodeJSONObject(value) + if err != nil { + return json.RawMessage(`{}`) + } + for _, field := range fields { + if getJSONPath(document, field) != nil { + setJSONPath(document, field, replacement) + } + } + encoded, err := json.Marshal(document) + if err != nil { + return json.RawMessage(`{}`) + } + return encoded +} + +func mergeJSONSecrets( + incoming json.RawMessage, + existing json.RawMessage, + fields []string, +) (json.RawMessage, error) { + next, err := decodeJSONObject(incoming) + if err != nil { + return nil, err + } + current, err := decodeJSONObject(existing) + if err != nil { + current = map[string]any{} + } + for _, field := range fields { + value := getJSONPath(next, field) + text, stringValue := value.(string) + if value == nil || (stringValue && (text == "" || text == SecretMask)) { + if previous := getJSONPath(current, field); previous != nil { + setJSONPath(next, field, previous) + } + } + } + return json.Marshal(next) +} + +func getJSONPath(document map[string]any, path string) any { + if strings.TrimSpace(path) == "" { + return nil + } + parts := strings.Split(path, ".") + var current any = document + for _, part := range parts { + object, ok := current.(map[string]any) + if !ok { + return nil + } + current, ok = object[part] + if !ok { + return nil + } + } + return current +} + +func setJSONPath(document map[string]any, path string, value any) { + if strings.TrimSpace(path) == "" { + return + } + parts := strings.Split(path, ".") + current := document + for _, part := range parts[:len(parts)-1] { + next, ok := current[part].(map[string]any) + if !ok { + next = map[string]any{} + current[part] = next + } + current = next + } + current[parts[len(parts)-1]] = value +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func nullableBool(value *bool) any { + if value == nil { + return nil + } + return boolInt(*value) +} + +func nullableInt(value *int) any { + if value == nil { + return nil + } + return *value +} diff --git a/internal/store/phone.go b/internal/store/phone.go new file mode 100644 index 0000000..021c9c9 --- /dev/null +++ b/internal/store/phone.go @@ -0,0 +1,79 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +func (s *Store) UpsertPhoneAssociation(ctx context.Context, value PhoneAssociation) error { + value.ICCID = strings.TrimSpace(value.ICCID) + value.DeviceID = strings.TrimSpace(value.DeviceID) + value.Number = strings.TrimSpace(value.Number) + value.Source = strings.TrimSpace(value.Source) + if value.ICCID == "" || value.Number == "" || value.Source == "" { + return errors.New("phone association ICCID, number, and source are required") + } + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO phone_associations ( + iccid, device_id, number, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(iccid) DO UPDATE SET + device_id = excluded.device_id, + number = excluded.number, + source = excluded.source, + updated_at = excluded.updated_at + `, + value.ICCID, + value.DeviceID, + value.Number, + value.Source, + createdAt.Unix(), + updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert phone association for ICCID %q: %w", value.ICCID, err) + } + return nil +} + +func (s *Store) PhoneAssociation( + ctx context.Context, + iccid string, +) (PhoneAssociation, error) { + var value PhoneAssociation + var createdAt, updatedAt int64 + err := s.db.QueryRowContext(ctx, ` + SELECT iccid, device_id, number, source, created_at, updated_at + FROM phone_associations + WHERE iccid = ? + `, strings.TrimSpace(iccid)).Scan( + &value.ICCID, + &value.DeviceID, + &value.Number, + &value.Source, + &createdAt, + &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return PhoneAssociation{}, ErrNotFound + } + if err != nil { + return PhoneAssociation{}, err + } + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} diff --git a/internal/store/phone_test.go b/internal/store/phone_test.go new file mode 100644 index 0000000..7447bd9 --- /dev/null +++ b/internal/store/phone_test.go @@ -0,0 +1,47 @@ +package store + +import ( + "context" + "errors" + "testing" +) + +func TestPhoneAssociationPersistsByICCID(t *testing.T) { + database, err := Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + + value := PhoneAssociation{ + ICCID: "89441000400311061404", + DeviceID: "ec20", + Number: "+447700900123", + Source: "ims_p_associated_uri", + } + if err := database.UpsertPhoneAssociation(context.Background(), value); err != nil { + t.Fatal(err) + } + got, err := database.PhoneAssociation(context.Background(), value.ICCID) + if err != nil { + t.Fatal(err) + } + if got.Number != value.Number || got.Source != value.Source || got.DeviceID != value.DeviceID { + t.Fatalf("PhoneAssociation() = %#v", got) + } + + value.Number = "+447700900456" + if err := database.UpsertPhoneAssociation(context.Background(), value); err != nil { + t.Fatal(err) + } + got, err = database.PhoneAssociation(context.Background(), value.ICCID) + if err != nil { + t.Fatal(err) + } + if got.Number != value.Number { + t.Fatalf("updated number = %q", got.Number) + } + if _, err := database.PhoneAssociation(context.Background(), "missing"); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing error = %v", err) + } +} diff --git a/internal/store/proxy.go b/internal/store/proxy.go new file mode 100644 index 0000000..75c74d3 --- /dev/null +++ b/internal/store/proxy.go @@ -0,0 +1,561 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" + "time" +) + +func (s *Store) UpsertLocalProxy(ctx context.Context, value LocalProxyConfig) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin local proxy update: %w", err) + } + defer tx.Rollback() + if err := upsertLocalProxy(ctx, tx, value); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit local proxy update: %w", err) + } + return nil +} + +func upsertLocalProxy( + ctx context.Context, + executor contextQueryExecer, + value LocalProxyConfig, +) error { + value.ID = strings.TrimSpace(value.ID) + value.Name = strings.TrimSpace(value.Name) + value.Mode = strings.ToLower(strings.TrimSpace(value.Mode)) + value.DeviceID = strings.TrimSpace(value.DeviceID) + value.ListenAddr = strings.TrimSpace(value.ListenAddr) + if value.ID == "" || value.Name == "" || value.DeviceID == "" { + return errors.New("local proxy id, name, and device id are required") + } + if value.Mode != "socks5" && value.Mode != "http" { + return fmt.Errorf("unsupported local proxy mode %q", value.Mode) + } + if value.ListenAddr == "" { + value.ListenAddr = "0.0.0.0" + } + if value.ListenPort < 1 || value.ListenPort > 65535 { + return errors.New("local proxy listen port must be between 1 and 65535") + } + extra, err := normalizeJSONObject(value.Extra) + if err != nil { + return fmt.Errorf("normalize local proxy extra data: %w", err) + } + + if !value.AuthEnabled { + value.Username = "" + value.Password = "" + } else { + current, currentErr := localProxy( + executor.QueryRowContext(ctx, localProxySelect+` WHERE id = ?`, value.ID), + ) + if currentErr != nil && !errors.Is(currentErr, ErrNotFound) { + return fmt.Errorf("read local proxy before update: %w", currentErr) + } + if currentErr == nil && (value.Password == "" || value.Password == SecretMask) { + value.Password = current.Password + } + if strings.TrimSpace(value.Username) == "" || value.Password == "" || value.Password == SecretMask { + return errors.New("enabled local proxy authentication requires username and password") + } + } + + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + _, err = executor.ExecContext(ctx, ` + INSERT INTO local_proxy_config ( + id, name, mode, device_id, listen_addr, listen_port, enabled, + auth_enabled, username, password, extra_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + mode = excluded.mode, + device_id = excluded.device_id, + listen_addr = excluded.listen_addr, + listen_port = excluded.listen_port, + enabled = excluded.enabled, + auth_enabled = excluded.auth_enabled, + username = excluded.username, + password = excluded.password, + extra_json = excluded.extra_json, + updated_at = excluded.updated_at + `, + value.ID, value.Name, value.Mode, value.DeviceID, value.ListenAddr, + value.ListenPort, boolInt(value.Enabled), boolInt(value.AuthEnabled), + value.Username, value.Password, string(extra), createdAt.Unix(), + updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert local proxy %q: %w", value.ID, err) + } + return nil +} + +func (s *Store) LocalProxy(ctx context.Context, id string) (LocalProxyConfig, error) { + return localProxy(s.db.QueryRowContext(ctx, localProxySelect+` WHERE id = ?`, id)) +} + +func (s *Store) ListLocalProxies(ctx context.Context) ([]LocalProxyConfig, error) { + rows, err := s.db.QueryContext(ctx, localProxySelect+` ORDER BY name COLLATE NOCASE, id`) + if err != nil { + return nil, fmt.Errorf("list local proxies: %w", err) + } + defer rows.Close() + values := make([]LocalProxyConfig, 0) + for rows.Next() { + value, err := localProxy(rows) + if err != nil { + return nil, fmt.Errorf("scan local proxy: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate local proxies: %w", err) + } + return values, nil +} + +func (s *Store) ReplaceLocalProxies(ctx context.Context, values []LocalProxyConfig) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin local proxy replacement: %w", err) + } + defer tx.Rollback() + + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + if _, duplicate := seen[value.ID]; duplicate { + return fmt.Errorf("duplicate local proxy id %q", value.ID) + } + if err := upsertLocalProxy(ctx, tx, value); err != nil { + return fmt.Errorf("replace local proxy item %d: %w", index, err) + } + seen[value.ID] = struct{}{} + } + + rows, err := tx.QueryContext(ctx, `SELECT id FROM local_proxy_config`) + if err != nil { + return fmt.Errorf("list stale local proxies: %w", err) + } + var stale []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return fmt.Errorf("scan stale local proxy: %w", err) + } + if _, keep := seen[id]; !keep { + stale = append(stale, id) + } + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close local proxy cursor: %w", err) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("iterate stale local proxies: %w", err) + } + for _, id := range stale { + if _, err := tx.ExecContext(ctx, `DELETE FROM local_proxy_config WHERE id = ?`, id); err != nil { + return fmt.Errorf("delete stale local proxy %q: %w", id, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit local proxy replacement: %w", err) + } + return nil +} + +func (s *Store) DeleteLocalProxy(ctx context.Context, id string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM local_proxy_config WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete local proxy %q: %w", id, err) + } + return requireAffected(result) +} + +const localProxySelect = ` + SELECT id, name, mode, device_id, listen_addr, listen_port, enabled, + auth_enabled, username, password, extra_json, created_at, updated_at + FROM local_proxy_config` + +func localProxy(row rowScanner) (LocalProxyConfig, error) { + var value LocalProxyConfig + var enabled, authEnabled int + var extra string + var createdAt, updatedAt int64 + err := row.Scan( + &value.ID, &value.Name, &value.Mode, &value.DeviceID, + &value.ListenAddr, &value.ListenPort, &enabled, &authEnabled, + &value.Username, &value.Password, &extra, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return LocalProxyConfig{}, ErrNotFound + } + if err != nil { + return LocalProxyConfig{}, err + } + value.Enabled = enabled != 0 + value.AuthEnabled = authEnabled != 0 + value.Extra = []byte(extra) + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func (s *Store) UpsertUpstreamProxy(ctx context.Context, value UpstreamProxy) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin upstream proxy update: %w", err) + } + defer tx.Rollback() + if err := upsertUpstreamProxy(ctx, tx, value); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit upstream proxy update: %w", err) + } + return nil +} + +func upsertUpstreamProxy( + ctx context.Context, + executor contextQueryExecer, + value UpstreamProxy, +) error { + value.ID = strings.TrimSpace(value.ID) + value.Name = strings.TrimSpace(value.Name) + value.Addr = strings.TrimSpace(value.Addr) + value.Username = strings.TrimSpace(value.Username) + if value.ID == "" || value.Name == "" || value.Addr == "" { + return errors.New("upstream proxy id, name, and address are required") + } + extra, err := normalizeJSONObject(value.Extra) + if err != nil { + return fmt.Errorf("normalize upstream proxy extra data: %w", err) + } + if value.Username == "" { + value.Password = "" + } else if value.Password == "" || value.Password == SecretMask { + current, currentErr := upstreamProxy( + executor.QueryRowContext(ctx, upstreamProxySelect+` WHERE id = ?`, value.ID), + ) + if currentErr != nil && !errors.Is(currentErr, ErrNotFound) { + return fmt.Errorf("read upstream proxy before update: %w", currentErr) + } + if currentErr == nil { + value.Password = current.Password + } + if value.Password == SecretMask { + value.Password = "" + } + } + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + _, err = executor.ExecContext(ctx, ` + INSERT INTO upstream_proxies ( + id, name, addr, username, password, enabled, extra_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + addr = excluded.addr, + username = excluded.username, + password = excluded.password, + enabled = excluded.enabled, + extra_json = excluded.extra_json, + updated_at = excluded.updated_at + `, + value.ID, value.Name, value.Addr, value.Username, value.Password, + boolInt(value.Enabled), string(extra), createdAt.Unix(), updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert upstream proxy %q: %w", value.ID, err) + } + return nil +} + +func (s *Store) UpstreamProxy(ctx context.Context, id string) (UpstreamProxy, error) { + return upstreamProxy(s.db.QueryRowContext(ctx, upstreamProxySelect+` WHERE id = ?`, id)) +} + +func (s *Store) ListUpstreamProxies(ctx context.Context) ([]UpstreamProxy, error) { + rows, err := s.db.QueryContext(ctx, upstreamProxySelect+` ORDER BY name COLLATE NOCASE, id`) + if err != nil { + return nil, fmt.Errorf("list upstream proxies: %w", err) + } + defer rows.Close() + values := make([]UpstreamProxy, 0) + for rows.Next() { + value, err := upstreamProxy(rows) + if err != nil { + return nil, fmt.Errorf("scan upstream proxy: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate upstream proxies: %w", err) + } + return values, nil +} + +func (s *Store) DeleteUpstreamProxy(ctx context.Context, id string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM upstream_proxies WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete upstream proxy %q: %w", id, err) + } + return requireAffected(result) +} + +const upstreamProxySelect = ` + SELECT id, name, addr, username, password, enabled, extra_json, + created_at, updated_at + FROM upstream_proxies` + +func upstreamProxy(row rowScanner) (UpstreamProxy, error) { + var value UpstreamProxy + var enabled int + var extra string + var createdAt, updatedAt int64 + err := row.Scan( + &value.ID, &value.Name, &value.Addr, &value.Username, + &value.Password, &enabled, &extra, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return UpstreamProxy{}, ErrNotFound + } + if err != nil { + return UpstreamProxy{}, err + } + value.Enabled = enabled != 0 + value.Extra = []byte(extra) + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func (s *Store) UpsertDeviceProxyBinding(ctx context.Context, value DeviceProxyBinding) error { + value.DeviceID = strings.TrimSpace(value.DeviceID) + value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID) + if value.DeviceID == "" || value.UpstreamProxyID == "" { + return errors.New("device proxy binding requires device and upstream proxy IDs") + } + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO device_proxy_bindings ( + device_id, upstream_proxy_id, created_at, updated_at + ) VALUES (?, ?, ?, ?) + ON CONFLICT(device_id) DO UPDATE SET + upstream_proxy_id = excluded.upstream_proxy_id, + updated_at = excluded.updated_at + `, value.DeviceID, value.UpstreamProxyID, createdAt.Unix(), updatedAt.Unix()) + if err != nil { + return fmt.Errorf("upsert proxy binding for device %q: %w", value.DeviceID, err) + } + return nil +} + +func (s *Store) DeviceProxyBinding(ctx context.Context, deviceID string) (DeviceProxyBinding, error) { + return deviceProxyBinding(s.db.QueryRowContext( + ctx, + deviceProxyBindingSelect+` WHERE device_id = ?`, + strings.TrimSpace(deviceID), + )) +} + +func (s *Store) ListDeviceProxyBindings(ctx context.Context) ([]DeviceProxyBinding, error) { + rows, err := s.db.QueryContext(ctx, deviceProxyBindingSelect+` ORDER BY device_id`) + if err != nil { + return nil, fmt.Errorf("list device proxy bindings: %w", err) + } + defer rows.Close() + values := make([]DeviceProxyBinding, 0) + for rows.Next() { + value, err := deviceProxyBinding(rows) + if err != nil { + return nil, fmt.Errorf("scan device proxy binding: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate device proxy bindings: %w", err) + } + return values, nil +} + +func (s *Store) DeleteDeviceProxyBinding(ctx context.Context, deviceID string) error { + result, err := s.db.ExecContext( + ctx, + `DELETE FROM device_proxy_bindings WHERE device_id = ?`, + strings.TrimSpace(deviceID), + ) + if err != nil { + return fmt.Errorf("delete proxy binding for device %q: %w", deviceID, err) + } + return requireAffected(result) +} + +const deviceProxyBindingSelect = ` + SELECT device_id, upstream_proxy_id, created_at, updated_at + FROM device_proxy_bindings` + +func deviceProxyBinding(row rowScanner) (DeviceProxyBinding, error) { + var value DeviceProxyBinding + var createdAt, updatedAt int64 + err := row.Scan(&value.DeviceID, &value.UpstreamProxyID, &createdAt, &updatedAt) + if errors.Is(err, sql.ErrNoRows) { + return DeviceProxyBinding{}, ErrNotFound + } + if err != nil { + return DeviceProxyBinding{}, err + } + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func (s *Store) UpsertCountryRule(ctx context.Context, value CountryRule) error { + value.CountryCode = strings.ToUpper(strings.TrimSpace(value.CountryCode)) + value.CountryName = strings.TrimSpace(value.CountryName) + value.UpstreamProxyID = strings.TrimSpace(value.UpstreamProxyID) + if len(value.CountryCode) != 2 { + return errors.New("country rule requires a two-letter country code") + } + for _, character := range value.CountryCode { + if character < 'A' || character > 'Z' { + return errors.New("country rule requires an ISO alpha-2 country code") + } + } + if value.UpstreamProxyID == "" { + return errors.New("country rule upstream proxy id is required") + } + extra, err := normalizeJSONObject(value.Extra) + if err != nil { + return fmt.Errorf("normalize country rule extra data: %w", err) + } + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + _, err = s.db.ExecContext(ctx, ` + INSERT INTO country_rules ( + country_code, country_name, upstream_proxy_id, enabled, + extra_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(country_code) DO UPDATE SET + country_name = excluded.country_name, + upstream_proxy_id = excluded.upstream_proxy_id, + enabled = excluded.enabled, + extra_json = excluded.extra_json, + updated_at = excluded.updated_at + `, + value.CountryCode, value.CountryName, value.UpstreamProxyID, + boolInt(value.Enabled), string(extra), createdAt.Unix(), updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert country rule %q: %w", value.CountryCode, err) + } + return nil +} + +func (s *Store) CountryRule(ctx context.Context, countryCode string) (CountryRule, error) { + return countryRule(s.db.QueryRowContext( + ctx, + countryRuleSelect+` WHERE country_code = ?`, + strings.ToUpper(strings.TrimSpace(countryCode)), + )) +} + +func (s *Store) ListCountryRules(ctx context.Context) ([]CountryRule, error) { + rows, err := s.db.QueryContext(ctx, countryRuleSelect+` ORDER BY country_code`) + if err != nil { + return nil, fmt.Errorf("list country rules: %w", err) + } + defer rows.Close() + values := make([]CountryRule, 0) + for rows.Next() { + value, err := countryRule(rows) + if err != nil { + return nil, fmt.Errorf("scan country rule: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate country rules: %w", err) + } + return values, nil +} + +func (s *Store) DeleteCountryRule(ctx context.Context, countryCode string) error { + result, err := s.db.ExecContext( + ctx, + `DELETE FROM country_rules WHERE country_code = ?`, + strings.ToUpper(strings.TrimSpace(countryCode)), + ) + if err != nil { + return fmt.Errorf("delete country rule %q: %w", countryCode, err) + } + return requireAffected(result) +} + +const countryRuleSelect = ` + SELECT country_code, country_name, upstream_proxy_id, enabled, + extra_json, created_at, updated_at + FROM country_rules` + +func countryRule(row rowScanner) (CountryRule, error) { + var value CountryRule + var enabled int + var extra string + var createdAt, updatedAt int64 + err := row.Scan( + &value.CountryCode, &value.CountryName, &value.UpstreamProxyID, + &enabled, &extra, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return CountryRule{}, ErrNotFound + } + if err != nil { + return CountryRule{}, err + } + value.Enabled = enabled != 0 + value.Extra = []byte(extra) + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} diff --git a/internal/store/settings.go b/internal/store/settings.go new file mode 100644 index 0000000..7aefd06 --- /dev/null +++ b/internal/store/settings.go @@ -0,0 +1,586 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +func DefaultNotificationSensitiveFields(channel string) []string { + switch strings.ToLower(strings.TrimSpace(channel)) { + case "telegram": + return []string{"bot_token"} + case "email": + return []string{"password"} + case "webhook": + return []string{"secret"} + case "pushplus": + return []string{"token"} + default: + return nil + } +} + +func (s *Store) UpsertNotificationSetting( + ctx context.Context, + value NotificationSetting, +) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin notification setting update: %w", err) + } + defer tx.Rollback() + if err := upsertNotificationSetting(ctx, tx, value); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit notification setting update: %w", err) + } + return nil +} + +func upsertNotificationSetting( + ctx context.Context, + executor contextQueryExecer, + value NotificationSetting, +) error { + value.Channel = strings.ToLower(strings.TrimSpace(value.Channel)) + if value.Channel == "" { + return errors.New("notification channel is required") + } + config, err := normalizeJSONObject(value.Config) + if err != nil { + return fmt.Errorf("normalize %s notification config: %w", value.Channel, err) + } + + current, currentErr := notificationSetting(executor.QueryRowContext( + ctx, + notificationSettingSelect+` WHERE channel = ?`, + value.Channel, + )) + if currentErr != nil && !errors.Is(currentErr, ErrNotFound) { + return fmt.Errorf("read %s notification setting before update: %w", value.Channel, currentErr) + } + fields := uniqueNonemptyStrings( + DefaultNotificationSensitiveFields(value.Channel), + value.SensitiveFields, + ) + if currentErr == nil { + fields = uniqueNonemptyStrings(fields, current.SensitiveFields) + config, err = mergeJSONSecrets(config, current.Config, fields) + if err != nil { + return fmt.Errorf("preserve %s notification secrets: %w", value.Channel, err) + } + } + fieldsJSON, err := json.Marshal(fields) + if err != nil { + return fmt.Errorf("encode notification sensitive fields: %w", err) + } + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + _, err = executor.ExecContext(ctx, ` + INSERT INTO notification_settings ( + channel, enabled, config_json, sensitive_fields_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(channel) DO UPDATE SET + enabled = excluded.enabled, + config_json = excluded.config_json, + sensitive_fields_json = excluded.sensitive_fields_json, + updated_at = excluded.updated_at + `, + value.Channel, boolInt(value.Enabled), string(config), + string(fieldsJSON), createdAt.Unix(), updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert %s notification setting: %w", value.Channel, err) + } + return nil +} + +// SaveNotificationSettings applies a multi-channel settings form atomically. +func (s *Store) SaveNotificationSettings( + ctx context.Context, + values []NotificationSetting, +) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin notification settings batch: %w", err) + } + defer tx.Rollback() + seen := make(map[string]struct{}, len(values)) + for index, value := range values { + channel := strings.ToLower(strings.TrimSpace(value.Channel)) + if _, duplicate := seen[channel]; duplicate { + return fmt.Errorf("duplicate notification channel %q", channel) + } + if err := upsertNotificationSetting(ctx, tx, value); err != nil { + return fmt.Errorf("save notification channel %d: %w", index, err) + } + seen[channel] = struct{}{} + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit notification settings batch: %w", err) + } + return nil +} + +func (s *Store) NotificationSetting( + ctx context.Context, + channel string, +) (NotificationSetting, error) { + return notificationSetting(s.db.QueryRowContext( + ctx, + notificationSettingSelect+` WHERE channel = ?`, + strings.ToLower(strings.TrimSpace(channel)), + )) +} + +func (s *Store) ListNotificationSettings(ctx context.Context) ([]NotificationSetting, error) { + rows, err := s.db.QueryContext(ctx, notificationSettingSelect+` ORDER BY channel`) + if err != nil { + return nil, fmt.Errorf("list notification settings: %w", err) + } + defer rows.Close() + values := make([]NotificationSetting, 0) + for rows.Next() { + value, err := notificationSetting(rows) + if err != nil { + return nil, fmt.Errorf("scan notification setting: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate notification settings: %w", err) + } + return values, nil +} + +func (s *Store) DeleteNotificationSetting(ctx context.Context, channel string) error { + result, err := s.db.ExecContext( + ctx, + `DELETE FROM notification_settings WHERE channel = ?`, + strings.ToLower(strings.TrimSpace(channel)), + ) + if err != nil { + return fmt.Errorf("delete notification setting %q: %w", channel, err) + } + return requireAffected(result) +} + +const notificationSettingSelect = ` + SELECT channel, enabled, config_json, sensitive_fields_json, + created_at, updated_at + FROM notification_settings` + +func notificationSetting(row rowScanner) (NotificationSetting, error) { + var value NotificationSetting + var enabled int + var config, fields string + var createdAt, updatedAt int64 + err := row.Scan( + &value.Channel, &enabled, &config, &fields, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return NotificationSetting{}, ErrNotFound + } + if err != nil { + return NotificationSetting{}, err + } + if err := json.Unmarshal([]byte(fields), &value.SensitiveFields); err != nil { + return NotificationSetting{}, fmt.Errorf("decode sensitive fields: %w", err) + } + value.Enabled = enabled != 0 + value.Config = []byte(config) + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func uniqueNonemptyStrings(groups ...[]string) []string { + seen := make(map[string]struct{}) + for _, group := range groups { + for _, item := range group { + item = strings.TrimSpace(item) + if item != "" { + seen[item] = struct{}{} + } + } + } + result := make([]string, 0, len(seen)) + for item := range seen { + result = append(result, item) + } + sort.Strings(result) + return result +} + +func (s *Store) UpsertAppSetting(ctx context.Context, value AppSetting) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin app setting update: %w", err) + } + defer tx.Rollback() + if err := upsertAppSetting(ctx, tx, value); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit app setting update: %w", err) + } + return nil +} + +func upsertAppSetting( + ctx context.Context, + executor contextQueryExecer, + value AppSetting, +) error { + value.Key = strings.TrimSpace(value.Key) + if value.Key == "" { + return errors.New("app setting key is required") + } + normalized, err := normalizeJSONValue(value.Value) + if err != nil { + return fmt.Errorf("normalize app setting %q: %w", value.Key, err) + } + if value.Sensitive && maskedJSONValue(normalized) { + current, currentErr := appSetting(executor.QueryRowContext( + ctx, + appSettingSelect+` WHERE key = ?`, + value.Key, + )) + switch { + case currentErr == nil: + normalized = current.Value + case errors.Is(currentErr, ErrNotFound): + return fmt.Errorf("new sensitive app setting %q requires a value", value.Key) + default: + return fmt.Errorf("read app setting before update: %w", currentErr) + } + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = time.Now().UTC() + } + _, err = executor.ExecContext(ctx, ` + INSERT INTO app_settings (key, value_json, sensitive, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(key) DO UPDATE SET + value_json = excluded.value_json, + sensitive = excluded.sensitive, + updated_at = excluded.updated_at + `, value.Key, string(normalized), boolInt(value.Sensitive), updatedAt.Unix()) + if err != nil { + return fmt.Errorf("upsert app setting %q: %w", value.Key, err) + } + return nil +} + +func (s *Store) AppSetting(ctx context.Context, key string) (AppSetting, error) { + return appSetting(s.db.QueryRowContext( + ctx, + appSettingSelect+` WHERE key = ?`, + strings.TrimSpace(key), + )) +} + +func (s *Store) ListAppSettings(ctx context.Context) ([]AppSetting, error) { + rows, err := s.db.QueryContext(ctx, appSettingSelect+` ORDER BY key`) + if err != nil { + return nil, fmt.Errorf("list app settings: %w", err) + } + defer rows.Close() + values := make([]AppSetting, 0) + for rows.Next() { + value, err := appSetting(rows) + if err != nil { + return nil, fmt.Errorf("scan app setting: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate app settings: %w", err) + } + return values, nil +} + +func (s *Store) DeleteAppSetting(ctx context.Context, key string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM app_settings WHERE key = ?`, key) + if err != nil { + return fmt.Errorf("delete app setting %q: %w", key, err) + } + return requireAffected(result) +} + +const appSettingSelect = ` + SELECT key, value_json, sensitive, updated_at + FROM app_settings` + +func appSetting(row rowScanner) (AppSetting, error) { + var value AppSetting + var sensitive int + var raw string + var updatedAt int64 + err := row.Scan(&value.Key, &raw, &sensitive, &updatedAt) + if errors.Is(err, sql.ErrNoRows) { + return AppSetting{}, ErrNotFound + } + if err != nil { + return AppSetting{}, err + } + value.Value = []byte(raw) + value.Sensitive = sensitive != 0 + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func maskedJSONValue(value json.RawMessage) bool { + if bytes.Equal(bytes.TrimSpace(value), []byte(`null`)) { + return true + } + var text string + if json.Unmarshal(value, &text) == nil { + return text == "" || text == SecretMask + } + return false +} + +func (s *Store) UpsertCardPolicy(ctx context.Context, value CardPolicy) error { + value.ICCID = strings.TrimSpace(value.ICCID) + if value.ICCID == "" { + return errors.New("card policy ICCID is required") + } + value.IPVersion = strings.ToUpper(strings.TrimSpace(value.IPVersion)) + switch value.IPVersion { + case "", "IP", "IPV6", "IPV4V6": + default: + return fmt.Errorf("unsupported card policy IP version %q", value.IPVersion) + } + if value.VoWiFiEnabled && value.AirplaneEnabled { + return errors.New("VoWiFi and airplane mode cannot both be enabled") + } + now := time.Now().UTC() + createdAt := value.CreatedAt + if createdAt.IsZero() { + createdAt = now + } + updatedAt := value.UpdatedAt + if updatedAt.IsZero() { + updatedAt = now + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO card_policies ( + iccid, network_enabled, vowifi_enabled, airplane_enabled, + apn, ip_version, source, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(iccid) DO UPDATE SET + network_enabled = excluded.network_enabled, + vowifi_enabled = excluded.vowifi_enabled, + airplane_enabled = excluded.airplane_enabled, + apn = excluded.apn, + ip_version = excluded.ip_version, + source = excluded.source, + updated_at = excluded.updated_at + `, + value.ICCID, boolInt(value.NetworkEnabled), boolInt(value.VoWiFiEnabled), + boolInt(value.AirplaneEnabled), value.APN, value.IPVersion, + value.Source, createdAt.Unix(), updatedAt.Unix(), + ) + if err != nil { + return fmt.Errorf("upsert card policy %q: %w", value.ICCID, err) + } + return nil +} + +func (s *Store) CardPolicy(ctx context.Context, iccid string) (CardPolicy, error) { + return cardPolicy(s.db.QueryRowContext( + ctx, + cardPolicySelect+` WHERE iccid = ?`, + strings.TrimSpace(iccid), + )) +} + +func (s *Store) ListCardPolicies(ctx context.Context) ([]CardPolicy, error) { + rows, err := s.db.QueryContext(ctx, cardPolicySelect+` ORDER BY iccid`) + if err != nil { + return nil, fmt.Errorf("list card policies: %w", err) + } + defer rows.Close() + values := make([]CardPolicy, 0) + for rows.Next() { + value, err := cardPolicy(rows) + if err != nil { + return nil, fmt.Errorf("scan card policy: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate card policies: %w", err) + } + return values, nil +} + +func (s *Store) DeleteCardPolicy(ctx context.Context, iccid string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM card_policies WHERE iccid = ?`, iccid) + if err != nil { + return fmt.Errorf("delete card policy %q: %w", iccid, err) + } + return requireAffected(result) +} + +const cardPolicySelect = ` + SELECT iccid, network_enabled, vowifi_enabled, airplane_enabled, + apn, ip_version, source, created_at, updated_at + FROM card_policies` + +func cardPolicy(row rowScanner) (CardPolicy, error) { + var value CardPolicy + var networkEnabled, vowifiEnabled, airplaneEnabled int + var createdAt, updatedAt int64 + err := row.Scan( + &value.ICCID, &networkEnabled, &vowifiEnabled, &airplaneEnabled, + &value.APN, &value.IPVersion, &value.Source, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return CardPolicy{}, ErrNotFound + } + if err != nil { + return CardPolicy{}, err + } + value.NetworkEnabled = networkEnabled != 0 + value.VoWiFiEnabled = vowifiEnabled != 0 + value.AirplaneEnabled = airplaneEnabled != 0 + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func (s *Store) UpsertTrafficBucket(ctx context.Context, value TrafficBucket) error { + return s.writeTrafficBucket(ctx, value, false) +} + +// AddTrafficBucket atomically accumulates counters for concurrent collectors. +func (s *Store) AddTrafficBucket(ctx context.Context, value TrafficBucket) error { + return s.writeTrafficBucket(ctx, value, true) +} + +func (s *Store) writeTrafficBucket( + ctx context.Context, + value TrafficBucket, + accumulate bool, +) error { + value.DeviceID = strings.TrimSpace(value.DeviceID) + value.Bucket = strings.TrimSpace(value.Bucket) + if value.DeviceID == "" || value.Bucket == "" { + return errors.New("traffic bucket device id and bucket are required") + } + if value.PeriodStart.IsZero() { + return errors.New("traffic bucket period start is required") + } + if value.RXBytes < 0 || value.TXBytes < 0 { + return errors.New("traffic byte counters cannot be negative") + } + update := ` + rx_bytes = excluded.rx_bytes, + tx_bytes = excluded.tx_bytes` + if accumulate { + update = ` + rx_bytes = traffic_buckets.rx_bytes + excluded.rx_bytes, + tx_bytes = traffic_buckets.tx_bytes + excluded.tx_bytes` + } + _, err := s.db.ExecContext(ctx, ` + INSERT INTO traffic_buckets ( + device_id, bucket, period_start, rx_bytes, tx_bytes + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(device_id, bucket, period_start) DO UPDATE SET`+update, + value.DeviceID, value.Bucket, value.PeriodStart.UTC().Unix(), + value.RXBytes, value.TXBytes, + ) + if err != nil { + return fmt.Errorf("write traffic bucket: %w", err) + } + return nil +} + +func (s *Store) ListTrafficBuckets( + ctx context.Context, + filter TrafficFilter, +) ([]TrafficBucket, error) { + clauses := make([]string, 0, 4) + args := make([]any, 0, 5) + if filter.DeviceID != "" { + clauses = append(clauses, `device_id = ?`) + args = append(args, filter.DeviceID) + } + if filter.Bucket != "" { + clauses = append(clauses, `bucket = ?`) + args = append(args, filter.Bucket) + } + if !filter.Since.IsZero() { + clauses = append(clauses, `period_start >= ?`) + args = append(args, filter.Since.UTC().Unix()) + } + if !filter.Until.IsZero() { + clauses = append(clauses, `period_start < ?`) + args = append(args, filter.Until.UTC().Unix()) + } + query := ` + SELECT device_id, bucket, period_start, rx_bytes, tx_bytes + FROM traffic_buckets` + if len(clauses) > 0 { + query += ` WHERE ` + strings.Join(clauses, ` AND `) + } + query += ` ORDER BY period_start ASC, device_id LIMIT ?` + args = append(args, normalizedLimit(filter.Limit)) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list traffic buckets: %w", err) + } + defer rows.Close() + values := make([]TrafficBucket, 0) + for rows.Next() { + var value TrafficBucket + var periodStart int64 + if err := rows.Scan( + &value.DeviceID, &value.Bucket, &periodStart, + &value.RXBytes, &value.TXBytes, + ); err != nil { + return nil, fmt.Errorf("scan traffic bucket: %w", err) + } + value.PeriodStart = time.Unix(periodStart, 0).UTC() + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate traffic buckets: %w", err) + } + return values, nil +} + +func (s *Store) DeleteTrafficBefore(ctx context.Context, before time.Time) (int64, error) { + result, err := s.db.ExecContext( + ctx, + `DELETE FROM traffic_buckets WHERE period_start < ?`, + before.UTC().Unix(), + ) + if err != nil { + return 0, fmt.Errorf("delete old traffic buckets: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("read deleted traffic bucket count: %w", err) + } + return affected, nil +} diff --git a/internal/store/sms.go b/internal/store/sms.go new file mode 100644 index 0000000..05b608f --- /dev/null +++ b/internal/store/sms.go @@ -0,0 +1,550 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "time" +) + +type contextQueryExecer interface { + contextExecer + QueryRowContext(context.Context, string, ...any) *sql.Row +} + +// SaveSMSMessage inserts a new message or updates an existing record. A +// non-empty (device_id, message_id) pair is idempotent for modem retries. +func (s *Store) SaveSMSMessage(ctx context.Context, value SMSMessage) (SMSMessage, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return SMSMessage{}, fmt.Errorf("begin SMS update: %w", err) + } + defer tx.Rollback() + saved, err := saveSMSMessage(ctx, tx, value) + if err != nil { + return SMSMessage{}, err + } + if err := tx.Commit(); err != nil { + return SMSMessage{}, fmt.Errorf("commit SMS update: %w", err) + } + return saved, nil +} + +func saveSMSMessage( + ctx context.Context, + executor contextQueryExecer, + value SMSMessage, +) (SMSMessage, error) { + value.DeviceID = strings.TrimSpace(value.DeviceID) + value.Peer = strings.TrimSpace(value.Peer) + value.Direction = strings.ToLower(strings.TrimSpace(value.Direction)) + if value.DeviceID == "" { + return SMSMessage{}, errors.New("SMS device id is required") + } + if value.Peer == "" { + return SMSMessage{}, errors.New("SMS peer is required") + } + switch value.Direction { + case "inbound", "outbound", "received", "sent": + default: + return SMSMessage{}, fmt.Errorf("unsupported SMS direction %q", value.Direction) + } + if value.PartsTotal == 0 { + value.PartsTotal = 1 + } + if value.PartsTotal < 1 { + return SMSMessage{}, errors.New("SMS parts total must be positive") + } + extra, err := normalizeJSONObject(value.Extra) + if err != nil { + return SMSMessage{}, fmt.Errorf("normalize SMS extra data: %w", err) + } + now := time.Now().UTC() + if value.Timestamp.IsZero() { + value.Timestamp = now + } + if value.CreatedAt.IsZero() { + value.CreatedAt = now + } + if value.UpdatedAt.IsZero() { + value.UpdatedAt = now + } + + if value.ID > 0 { + result, err := executor.ExecContext(ctx, ` + UPDATE sms_messages SET + message_id = ?, device_id = ?, imsi = ?, peer = ?, + direction = ?, body = ?, message_time = ?, status = ?, + source = ?, parts_total = ?, delivery_state = ?, is_read = ?, + extra_json = ?, updated_at = ? + WHERE id = ? + `, + value.MessageID, value.DeviceID, value.IMSI, value.Peer, + value.Direction, value.Body, value.Timestamp.Unix(), value.Status, + value.Source, value.PartsTotal, value.DeliveryState, + boolInt(value.Read), string(extra), value.UpdatedAt.Unix(), value.ID, + ) + if err != nil { + return SMSMessage{}, fmt.Errorf("update SMS %d: %w", value.ID, err) + } + if err := requireAffected(result); err != nil { + return SMSMessage{}, err + } + return scanSMSMessage(executor.QueryRowContext(ctx, smsMessageSelect+` WHERE id = ?`, value.ID)) + } + + result, err := executor.ExecContext(ctx, ` + INSERT INTO sms_messages ( + message_id, device_id, imsi, peer, direction, body, message_time, + status, source, parts_total, delivery_state, is_read, extra_json, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(device_id, message_id) WHERE message_id <> '' DO UPDATE SET + imsi = excluded.imsi, + peer = excluded.peer, + direction = excluded.direction, + body = excluded.body, + message_time = MIN(sms_messages.message_time, excluded.message_time), + status = excluded.status, + source = excluded.source, + parts_total = excluded.parts_total, + delivery_state = excluded.delivery_state, + is_read = excluded.is_read, + extra_json = excluded.extra_json, + updated_at = excluded.updated_at + `, + value.MessageID, value.DeviceID, value.IMSI, value.Peer, + value.Direction, value.Body, value.Timestamp.Unix(), value.Status, + value.Source, value.PartsTotal, value.DeliveryState, + boolInt(value.Read), string(extra), value.CreatedAt.Unix(), + value.UpdatedAt.Unix(), + ) + if err != nil { + return SMSMessage{}, fmt.Errorf("save SMS: %w", err) + } + if value.MessageID != "" { + return scanSMSMessage(executor.QueryRowContext( + ctx, + smsMessageSelect+` WHERE device_id = ? AND message_id = ?`, + value.DeviceID, + value.MessageID, + )) + } + id, err := result.LastInsertId() + if err != nil { + return SMSMessage{}, fmt.Errorf("read inserted SMS id: %w", err) + } + return scanSMSMessage(executor.QueryRowContext(ctx, smsMessageSelect+` WHERE id = ?`, id)) +} + +func (s *Store) SaveSMSMessages(ctx context.Context, values []SMSMessage) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin SMS batch: %w", err) + } + defer tx.Rollback() + for index, value := range values { + if _, err := saveSMSMessage(ctx, tx, value); err != nil { + return fmt.Errorf("save SMS batch item %d: %w", index, err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit SMS batch: %w", err) + } + return nil +} + +func (s *Store) SMSMessage(ctx context.Context, id int64) (SMSMessage, error) { + return scanSMSMessage(s.db.QueryRowContext(ctx, smsMessageSelect+` WHERE id = ?`, id)) +} + +// ApplySMSDeliveryReport attaches a TP-STATUS report to the newest matching +// outbound submission and advances its aggregate delivery state. Multipart +// messages become delivered only after every submitted part is reported. +func (s *Store) ApplySMSDeliveryReport(ctx context.Context, report SMSDeliveryReport) (SMSMessage, error) { + if report.DeviceID == "" || report.MessageReference < 0 || report.MessageReference > 255 { + return SMSMessage{}, errors.New("invalid SMS delivery report identity") + } + if report.ReceivedAt.IsZero() { + report.ReceivedAt = time.Now().UTC() + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return SMSMessage{}, fmt.Errorf("begin SMS delivery report: %w", err) + } + defer tx.Rollback() + query := smsMessageSelect + ` + WHERE device_id = ? + AND direction IN ('outbound', 'sent') + AND (? = '' OR imsi = ?) + AND (? = '' OR peer = ?) + AND (? = '' OR source = ?) + ORDER BY created_at DESC, id DESC + LIMIT 256` + rows, err := tx.QueryContext( + ctx, + query, + report.DeviceID, + report.IMSI, report.IMSI, + report.Peer, report.Peer, + report.Source, report.Source, + ) + if err != nil { + return SMSMessage{}, fmt.Errorf("find SMS delivery target: %w", err) + } + var target SMSMessage + var targetExtra map[string]any + for rows.Next() { + candidate, scanErr := scanSMSMessage(rows) + if scanErr != nil { + _ = rows.Close() + return SMSMessage{}, scanErr + } + extra := make(map[string]any) + if json.Unmarshal(candidate.Extra, &extra) != nil || !smsExtraHasReference(extra, report.MessageReference) { + continue + } + target, targetExtra = candidate, extra + break + } + if err := rows.Close(); err != nil { + return SMSMessage{}, err + } + if target.ID == 0 { + return SMSMessage{}, ErrNotFound + } + reports, _ := targetExtra["delivery_reports"].(map[string]any) + if reports == nil { + reports = make(map[string]any) + } + reportValue := map[string]any{ + "status_code": report.StatusCode, + "delivery_state": report.DeliveryState, + "received_at": report.ReceivedAt.UTC(), + } + if report.ServiceCenterTime != nil { + reportValue["service_center_timestamp"] = report.ServiceCenterTime.UTC() + } + if report.DischargeTime != nil { + reportValue["discharge_timestamp"] = report.DischargeTime.UTC() + } + reports[strconv.Itoa(report.MessageReference)] = reportValue + targetExtra["delivery_reports"] = reports + target.DeliveryState = aggregateSMSDeliveryState(targetExtra, reports) + target.Extra, err = json.Marshal(targetExtra) + if err != nil { + return SMSMessage{}, fmt.Errorf("encode SMS delivery reports: %w", err) + } + target.UpdatedAt = time.Now().UTC() + saved, err := saveSMSMessage(ctx, tx, target) + if err != nil { + return SMSMessage{}, err + } + if err := tx.Commit(); err != nil { + return SMSMessage{}, fmt.Errorf("commit SMS delivery report: %w", err) + } + return saved, nil +} + +func smsExtraHasReference(extra map[string]any, reference int) bool { + if numberAsInt(extra["message_reference"]) == reference { + return true + } + parts, _ := extra["part_results"].([]any) + for _, value := range parts { + part, _ := value.(map[string]any) + if numberAsInt(part["reference"]) == reference || + numberAsInt(part["messageReference"]) == reference || + numberAsInt(part["message_reference"]) == reference { + return true + } + } + return false +} + +func aggregateSMSDeliveryState(extra map[string]any, reports map[string]any) string { + parts, _ := extra["part_results"].([]any) + references := make([]int, 0, len(parts)) + for _, value := range parts { + part, _ := value.(map[string]any) + reference := numberAsInt(part["reference"]) + if reference < 0 { + reference = numberAsInt(part["messageReference"]) + } + if reference < 0 { + reference = numberAsInt(part["message_reference"]) + } + if reference >= 0 { + references = append(references, reference) + } + } + if len(references) == 0 { + if reference := numberAsInt(extra["message_reference"]); reference >= 0 { + references = append(references, reference) + } + } + if len(references) == 0 { + return "unknown" + } + delivered := 0 + for _, reference := range references { + value, found := reports[strconv.Itoa(reference)] + if !found { + continue + } + report, _ := value.(map[string]any) + state, _ := report["delivery_state"].(string) + switch state { + case "delivered": + delivered++ + case "permanent_error", "failed", "rejected": + return "failed" + } + } + if delivered == len(references) { + return "delivered" + } + return "pending_delivery_report" +} + +func numberAsInt(value any) int { + switch number := value.(type) { + case float64: + return int(number) + case int: + return number + case json.Number: + parsed, err := strconv.Atoi(string(number)) + if err == nil { + return parsed + } + } + return -1 +} + +func (s *Store) ListSMSMessages(ctx context.Context, filter SMSFilter) ([]SMSMessage, error) { + where, args := smsWhere(filter, "") + query := smsMessageSelect + where + ` ORDER BY message_time DESC, id DESC LIMIT ?` + args = append(args, normalizedLimit(filter.Limit)) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list SMS messages: %w", err) + } + defer rows.Close() + + values := make([]SMSMessage, 0) + for rows.Next() { + value, err := scanSMSMessage(rows) + if err != nil { + return nil, fmt.Errorf("scan SMS message: %w", err) + } + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate SMS messages: %w", err) + } + return values, nil +} + +func (s *Store) DeleteSMSMessage(ctx context.Context, id int64) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM sms_messages WHERE id = ?`, id) + if err != nil { + return fmt.Errorf("delete SMS %d: %w", id, err) + } + return requireAffected(result) +} + +func (s *Store) DeleteSMSThread( + ctx context.Context, + deviceID string, + imsi string, + peer string, +) (int64, error) { + result, err := s.db.ExecContext(ctx, ` + DELETE FROM sms_messages + WHERE device_id = ? AND imsi = ? AND peer = ? + `, deviceID, imsi, peer) + if err != nil { + return 0, fmt.Errorf("delete SMS thread: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("read deleted SMS count: %w", err) + } + if affected == 0 { + return 0, ErrNotFound + } + return affected, nil +} + +func (s *Store) MarkSMSThreadRead( + ctx context.Context, + deviceID string, + imsi string, + peer string, +) (int64, error) { + result, err := s.db.ExecContext(ctx, ` + UPDATE sms_messages + SET is_read = 1, updated_at = ? + WHERE device_id = ? AND imsi = ? AND peer = ? + AND direction IN ('inbound', 'received') AND is_read = 0 + `, time.Now().UTC().Unix(), deviceID, imsi, peer) + if err != nil { + return 0, fmt.Errorf("mark SMS thread read: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return 0, fmt.Errorf("read marked SMS count: %w", err) + } + return affected, nil +} + +// ListSMSContacts derives contacts and thread counters from messages. No +// duplicated contact/thread table can drift out of sync with message history. +func (s *Store) ListSMSContacts(ctx context.Context, filter SMSFilter) ([]SMSContact, error) { + where, args := smsWhere(filter, "m.") + query := ` + WITH ranked AS ( + SELECT + m.id, m.device_id, m.imsi, m.peer, m.body, m.message_time, + m.direction, + ROW_NUMBER() OVER ( + PARTITION BY m.device_id, m.imsi, m.peer + ORDER BY m.message_time DESC, m.id DESC + ) AS row_number, + SUM(CASE + WHEN m.direction IN ('inbound', 'received') AND m.is_read = 0 + THEN 1 ELSE 0 + END) OVER ( + PARTITION BY m.device_id, m.imsi, m.peer + ) AS unread_count, + COUNT(*) OVER ( + PARTITION BY m.device_id, m.imsi, m.peer + ) AS message_count + FROM sms_messages m` + where + ` + ) + SELECT + r.device_id, + COALESCE(d.name, ''), + r.imsi, + COALESCE(NULLIF(dr.phone_number, ''), NULLIF(vr.local_phone, ''), ''), + r.peer, + r.peer, + r.body, + r.message_time, + r.direction, + r.id, + r.unread_count, + r.message_count + FROM ranked r + LEFT JOIN devices d ON d.id = r.device_id + LEFT JOIN device_runtime dr ON dr.device_id = r.device_id + LEFT JOIN vowifi_runtime vr ON vr.device_id = r.device_id + WHERE r.row_number = 1 + ORDER BY r.message_time DESC, r.id DESC + LIMIT ?` + args = append(args, normalizedLimit(filter.Limit)) + rows, err := s.db.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list SMS contacts: %w", err) + } + defer rows.Close() + + values := make([]SMSContact, 0) + for rows.Next() { + var value SMSContact + var timestamp int64 + if err := rows.Scan( + &value.DeviceID, &value.DeviceName, &value.IMSI, + &value.LocalPhone, &value.Peer, &value.DisplayName, + &value.LastMessage, ×tamp, &value.Direction, + &value.LastSMSID, &value.UnreadCount, &value.MessageCount, + ); err != nil { + return nil, fmt.Errorf("scan SMS contact: %w", err) + } + value.LastTimestamp = time.Unix(timestamp, 0).UTC() + values = append(values, value) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate SMS contacts: %w", err) + } + return values, nil +} + +const smsMessageSelect = ` + SELECT id, message_id, device_id, imsi, peer, direction, body, + message_time, status, source, parts_total, delivery_state, is_read, + extra_json, created_at, updated_at + FROM sms_messages` + +func scanSMSMessage(row rowScanner) (SMSMessage, error) { + var value SMSMessage + var messageTime, createdAt, updatedAt int64 + var read int + var extra string + err := row.Scan( + &value.ID, &value.MessageID, &value.DeviceID, &value.IMSI, + &value.Peer, &value.Direction, &value.Body, &messageTime, + &value.Status, &value.Source, &value.PartsTotal, + &value.DeliveryState, &read, &extra, &createdAt, &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return SMSMessage{}, ErrNotFound + } + if err != nil { + return SMSMessage{}, err + } + value.Read = read != 0 + value.Extra = []byte(extra) + value.Timestamp = time.Unix(messageTime, 0).UTC() + value.CreatedAt = time.Unix(createdAt, 0).UTC() + value.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return value, nil +} + +func smsWhere(filter SMSFilter, prefix string) (string, []any) { + clauses := make([]string, 0, 6) + args := make([]any, 0, 6) + if filter.DeviceID != "" { + clauses = append(clauses, prefix+`device_id = ?`) + args = append(args, filter.DeviceID) + } + if filter.IMSI != "" { + clauses = append(clauses, prefix+`imsi = ?`) + args = append(args, filter.IMSI) + } + if filter.Peer != "" { + clauses = append(clauses, prefix+`peer = ?`) + args = append(args, filter.Peer) + } + if !filter.Since.IsZero() { + clauses = append(clauses, prefix+`message_time >= ?`) + args = append(args, filter.Since.UTC().Unix()) + } + if !filter.Until.IsZero() { + clauses = append(clauses, prefix+`message_time < ?`) + args = append(args, filter.Until.UTC().Unix()) + } + if filter.BeforeID > 0 { + clauses = append(clauses, prefix+`id < ?`) + args = append(args, filter.BeforeID) + } + if len(clauses) == 0 { + return "", args + } + return " WHERE " + strings.Join(clauses, " AND "), args +} + +func normalizedLimit(value int) int { + if value <= 0 { + return 100 + } + if value > 1000 { + return 1000 + } + return value +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..5da5eac --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,326 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + _ "modernc.org/sqlite" +) + +const schemaVersion = 6 + +var ErrNotFound = errors.New("store: not found") + +// Store owns the SQLite connection used by the process. +type Store struct { + db *sql.DB +} + +type Admin struct { + ID int64 + Username string + PasswordHash []byte + CreatedAt time.Time + UpdatedAt time.Time +} + +type Session struct { + TokenHash []byte + CSRFHash []byte + ExpiresAt time.Time + CreatedAt time.Time + Admin Admin +} + +// Open creates the parent directory, opens SQLite, applies safety pragmas and +// runs the built-in schema migration. +func Open(ctx context.Context, path string) (*Store, error) { + if err := prepareDatabasePath(path); err != nil { + return nil, err + } + + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("open sqlite: %w", err) + } + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + closeOnError := func(err error) (*Store, error) { + _ = db.Close() + return nil, err + } + + if err := db.PingContext(ctx); err != nil { + return closeOnError(fmt.Errorf("ping sqlite: %w", err)) + } + for _, pragma := range []string{ + "PRAGMA foreign_keys = ON", + "PRAGMA busy_timeout = 5000", + "PRAGMA journal_mode = WAL", + } { + if _, err := db.ExecContext(ctx, pragma); err != nil { + return closeOnError(fmt.Errorf("%s: %w", pragma, err)) + } + } + if err := migrate(ctx, db); err != nil { + return closeOnError(err) + } + if isFilesystemPath(path) { + if err := os.Chmod(path, 0o600); err != nil { + return closeOnError(fmt.Errorf("secure sqlite file: %w", err)) + } + } + return &Store{db: db}, nil +} + +func prepareDatabasePath(path string) error { + if !isFilesystemPath(path) { + return nil + } + parent := filepath.Dir(path) + if parent == "." { + return nil + } + if err := os.MkdirAll(parent, 0o750); err != nil { + return fmt.Errorf("create sqlite directory: %w", err) + } + return nil +} + +func isFilesystemPath(path string) bool { + return path != ":memory:" && !strings.HasPrefix(path, "file:") +} + +func migrate(ctx context.Context, db *sql.DB) error { + var version int + if err := db.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { + return fmt.Errorf("read sqlite schema version: %w", err) + } + if version > schemaVersion { + return fmt.Errorf("sqlite schema version %d is newer than supported version %d", version, schemaVersion) + } + if version == schemaVersion { + return nil + } + + for nextVersion := version + 1; nextVersion <= schemaVersion; nextVersion++ { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin sqlite migration %d: %w", nextVersion, err) + } + for _, statement := range migrationStatements(nextVersion) { + if _, err := tx.ExecContext(ctx, statement); err != nil { + _ = tx.Rollback() + return fmt.Errorf("apply sqlite migration %d: %w", nextVersion, err) + } + } + if _, err := tx.ExecContext( + ctx, + fmt.Sprintf("PRAGMA user_version = %d", nextVersion), + ); err != nil { + _ = tx.Rollback() + return fmt.Errorf("record sqlite migration %d: %w", nextVersion, err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit sqlite migration %d: %w", nextVersion, err) + } + } + return nil +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) Ready(ctx context.Context) error { + if err := s.db.PingContext(ctx); err != nil { + return err + } + var one int + if err := s.db.QueryRowContext(ctx, "SELECT 1").Scan(&one); err != nil { + return err + } + if one != 1 { + return errors.New("sqlite readiness query returned an unexpected value") + } + return nil +} + +func (s *Store) CurrentAdmin(ctx context.Context) (Admin, error) { + return scanAdmin(s.db.QueryRowContext(ctx, ` + SELECT id, username, password_hash, created_at, updated_at + FROM admins + WHERE id = 1 + `)) +} + +func (s *Store) AdminByUsername(ctx context.Context, username string) (Admin, error) { + return scanAdmin(s.db.QueryRowContext(ctx, ` + SELECT id, username, password_hash, created_at, updated_at + FROM admins + WHERE username = ? + `, username)) +} + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanAdmin(row rowScanner) (Admin, error) { + var admin Admin + var createdAt int64 + var updatedAt int64 + err := row.Scan( + &admin.ID, + &admin.Username, + &admin.PasswordHash, + &createdAt, + &updatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return Admin{}, ErrNotFound + } + if err != nil { + return Admin{}, err + } + admin.CreatedAt = time.Unix(createdAt, 0).UTC() + admin.UpdatedAt = time.Unix(updatedAt, 0).UTC() + return admin, nil +} + +// SetAdmin inserts or replaces the single configured administrator and +// atomically revokes all existing sessions. +func (s *Store) SetAdmin(ctx context.Context, username string, passwordHash []byte) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin admin update: %w", err) + } + defer tx.Rollback() + + now := time.Now().UTC().Unix() + _, err = tx.ExecContext(ctx, ` + INSERT INTO admins (id, username, password_hash, created_at, updated_at) + VALUES (1, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + username = excluded.username, + password_hash = excluded.password_hash, + updated_at = excluded.updated_at + `, username, passwordHash, now, now) + if err != nil { + return fmt.Errorf("set admin: %w", err) + } + if _, err := tx.ExecContext(ctx, "DELETE FROM sessions"); err != nil { + return fmt.Errorf("revoke sessions after admin update: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit admin update: %w", err) + } + return nil +} + +func (s *Store) DeleteAllSessions(ctx context.Context) error { + if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions"); err != nil { + return fmt.Errorf("delete all sessions: %w", err) + } + return nil +} + +func (s *Store) CreateSession( + ctx context.Context, + adminID int64, + tokenHash []byte, + csrfHash []byte, + expiresAt time.Time, +) error { + now := time.Now().UTC().Unix() + _, err := s.db.ExecContext(ctx, ` + INSERT INTO sessions (token_hash, admin_id, csrf_hash, expires_at, created_at) + VALUES (?, ?, ?, ?, ?) + `, tokenHash, adminID, csrfHash, expiresAt.UTC().Unix(), now) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + return nil +} + +func (s *Store) SessionByTokenHash(ctx context.Context, tokenHash []byte) (Session, error) { + var session Session + var expiresAt int64 + var createdAt int64 + var adminCreatedAt int64 + var adminUpdatedAt int64 + err := s.db.QueryRowContext(ctx, ` + SELECT + s.token_hash, + s.csrf_hash, + s.expires_at, + s.created_at, + a.id, + a.username, + a.created_at, + a.updated_at + FROM sessions s + JOIN admins a ON a.id = s.admin_id + WHERE s.token_hash = ? + `, tokenHash).Scan( + &session.TokenHash, + &session.CSRFHash, + &expiresAt, + &createdAt, + &session.Admin.ID, + &session.Admin.Username, + &adminCreatedAt, + &adminUpdatedAt, + ) + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, err + } + session.ExpiresAt = time.Unix(expiresAt, 0).UTC() + session.CreatedAt = time.Unix(createdAt, 0).UTC() + session.Admin.CreatedAt = time.Unix(adminCreatedAt, 0).UTC() + session.Admin.UpdatedAt = time.Unix(adminUpdatedAt, 0).UTC() + return session, nil +} + +func (s *Store) UpdateSessionCSRF(ctx context.Context, tokenHash []byte, csrfHash []byte) error { + result, err := s.db.ExecContext(ctx, ` + UPDATE sessions + SET csrf_hash = ? + WHERE token_hash = ? + `, csrfHash, tokenHash) + if err != nil { + return fmt.Errorf("update session csrf: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("read session update result: %w", err) + } + if affected == 0 { + return ErrNotFound + } + return nil +} + +func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error { + if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE token_hash = ?", tokenHash); err != nil { + return fmt.Errorf("delete session: %w", err) + } + return nil +} + +func (s *Store) DeleteExpiredSessions(ctx context.Context, now time.Time) error { + if _, err := s.db.ExecContext(ctx, "DELETE FROM sessions WHERE expires_at <= ?", now.UTC().Unix()); err != nil { + return fmt.Errorf("delete expired sessions: %w", err) + } + return nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..606ce93 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,90 @@ +package store + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +func TestStorePersistsAdminAndSessions(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "vocat.db") + + database, err := Open(ctx, path) + if err != nil { + t.Fatalf("Open() error = %v", err) + } + + passwordHash := []byte("password-hash") + if err := database.SetAdmin(ctx, "admin", passwordHash); err != nil { + t.Fatalf("SetAdmin() error = %v", err) + } + admin, err := database.AdminByUsername(ctx, "admin") + if err != nil { + t.Fatalf("AdminByUsername() error = %v", err) + } + + tokenHash := bytes.Repeat([]byte{1}, 32) + csrfHash := bytes.Repeat([]byte{2}, 32) + expiresAt := time.Now().UTC().Add(time.Hour).Truncate(time.Second) + if err := database.CreateSession(ctx, admin.ID, tokenHash, csrfHash, expiresAt); err != nil { + t.Fatalf("CreateSession() error = %v", err) + } + if err := database.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + database, err = Open(ctx, path) + if err != nil { + t.Fatalf("reopen error = %v", err) + } + defer database.Close() + + session, err := database.SessionByTokenHash(ctx, tokenHash) + if err != nil { + t.Fatalf("SessionByTokenHash() error = %v", err) + } + if session.Admin.Username != "admin" || !bytes.Equal(session.CSRFHash, csrfHash) { + t.Fatalf("unexpected session: %+v", session) + } + if !session.ExpiresAt.Equal(expiresAt) { + t.Fatalf("ExpiresAt = %v, want %v", session.ExpiresAt, expiresAt) + } +} + +func TestDeleteExpiredSessions(t *testing.T) { + ctx := context.Background() + database, err := Open(ctx, ":memory:") + if err != nil { + t.Fatalf("Open() error = %v", err) + } + defer database.Close() + + if err := database.SetAdmin(ctx, "admin", []byte("hash")); err != nil { + t.Fatal(err) + } + admin, err := database.CurrentAdmin(ctx) + if err != nil { + t.Fatal(err) + } + tokenHash := bytes.Repeat([]byte{3}, 32) + if err := database.CreateSession( + ctx, + admin.ID, + tokenHash, + bytes.Repeat([]byte{4}, 32), + time.Now().Add(-time.Minute), + ); err != nil { + t.Fatal(err) + } + + if err := database.DeleteExpiredSessions(ctx, time.Now()); err != nil { + t.Fatal(err) + } + if _, err := database.SessionByTokenHash(ctx, tokenHash); !errors.Is(err, ErrNotFound) { + t.Fatalf("SessionByTokenHash() error = %v, want ErrNotFound", err) + } +} diff --git a/internal/vowifi/ec20_adapter.go b/internal/vowifi/ec20_adapter.go new file mode 100644 index 0000000..0fa94ac --- /dev/null +++ b/internal/vowifi/ec20_adapter.go @@ -0,0 +1,1373 @@ +package vowifi + +import ( + "context" + "encoding/csv" + "encoding/hex" + "errors" + "fmt" + "io" + "sort" + "strconv" + "strings" + "sync" + "time" + + "vocat/internal/modem" +) + +var ( + ErrEC20SIMNotReady = errors.New("vocat: EC20 SIM is not ready") + ErrEC20MNCUnavailable = errors.New("vocat: EC20 SIM does not expose an explicit MNC length") + ErrEC20ApplicationAbsent = errors.New("vocat: EC20 has no usable USIM or ISIM application") + ErrEC20IdentityChanged = errors.New("vocat: EC20 SIM identity changed during authentication") + ErrEC20AKACommand = errors.New("vocat: EC20 USIM AUTHENTICATE command failed") + ErrEC20AKAResponse = errors.New("vocat: EC20 returned an invalid USIM AUTHENTICATE response") + ErrEC20AKAMACFailure = errors.New("vocat: EC20 USIM rejected the network authentication token") +) + +const ( + usimAIDPrefix = "A0000000871002" + isimAIDPrefix = "A0000000871004" + efADDecimal = 28589 // 0x6FAD + channelCleanupTimeout = 3 * time.Second +) + +// EC20ATExecutor is deliberately the same narrow shape as +// device.Manager.ExecuteAT. Production code passes the device manager directly; +// tests can use an evidence transcript without opening any serial device. +type EC20ATExecutor interface { + ExecuteAT(context.Context, string, string) (modem.Response, error) +} + +// EC20SensitiveATExecutor is implemented by device.Manager so an APDU carrying +// RAND and AUTN is not retained as a device error. The fallback exists only for +// small deterministic test executors. +type EC20SensitiveATExecutor interface { + ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error) +} + +type EC20AdapterOptions struct { + // PureAirplanePolicy reports the independent user policy. The adapter only + // changes the transactional CFUN projection used by VoWiFi and never + // changes this policy. + PureAirplanePolicy func(deviceID string) bool + + // HomePLMN supplies an explicit operator configuration when EF_AD omits + // the MNC length. The returned MCC/MNC must exactly prefix the live IMSI. + HomePLMN func(deviceID, iccid, imsi string) (mcc, mnc string, ok bool) + + // RestoreCellularData permits reactivating PDP contexts that were active + // before the VoWiFi transaction. It is deliberately false by default: + // VoWiFi must never start billable cellular data unless an operator has + // explicitly opted in to that separate behavior. + RestoreCellularData bool +} + +// EC20Adapter implements SIMIdentityReader, AKAProvider, and RadioController +// using standardized AT commands on the AT port selected by device.Manager. +// It never discovers or opens serial ports itself, so ttyUSB0 diagnostic cannot +// be selected accidentally. +type EC20Adapter struct { + executor EC20ATExecutor + options EC20AdapterOptions + + apduMu sync.Mutex + mu sync.Mutex + bindings map[string]ec20SIMBinding + checkpoints map[string]ec20RadioCheckpoint +} + +type ec20SIMBinding struct { + deviceID string + iccid string + imsi string + aid string + application string + basicChannel bool +} + +type ec20RadioCheckpoint struct { + activeCIDs []int +} + +var ( + _ SIMIdentityReader = (*EC20Adapter)(nil) + _ AKAProvider = (*EC20Adapter)(nil) + _ RadioController = (*EC20Adapter)(nil) +) + +func NewEC20Adapter( + executor EC20ATExecutor, + options EC20AdapterOptions, +) (*EC20Adapter, error) { + if executor == nil { + return nil, errors.New("vocat: EC20 AT executor is required") + } + return &EC20Adapter{ + executor: executor, + options: options, + bindings: make(map[string]ec20SIMBinding), + checkpoints: make(map[string]ec20RadioCheckpoint), + }, nil +} + +func (adapter *EC20Adapter) ReadIdentity( + ctx context.Context, + deviceID string, +) (SIMIdentity, error) { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + return SIMIdentity{}, errors.New("vocat: EC20 device ID is required") + } + + pin, err := adapter.execute(ctx, deviceID, "AT+CPIN?") + if err != nil { + return SIMIdentity{}, fmt.Errorf("read EC20 SIM state: %w", err) + } + if !responseContainsValue(pin, "+CPIN:", "READY") { + return SIMIdentity{}, ErrEC20SIMNotReady + } + + imsiResponse, err := adapter.execute(ctx, deviceID, "AT+CIMI") + if err != nil { + return SIMIdentity{}, fmt.Errorf("read EC20 IMSI: %w", err) + } + imsi := digitIdentifier(imsiResponse, []string{"+CIMI:"}, 10, 18) + if imsi == "" { + return SIMIdentity{}, errors.New("vocat: EC20 returned no valid IMSI") + } + iccid, err := adapter.readICCID(ctx, deviceID) + if err != nil { + return SIMIdentity{}, err + } + + imeiResponse, err := adapter.execute(ctx, deviceID, "AT+CGSN") + if err != nil { + return SIMIdentity{}, fmt.Errorf("read EC20 IMEI: %w", err) + } + imei := digitIdentifier(imeiResponse, []string{"+CGSN:", "+GSN:"}, 14, 17) + if imei == "" { + return SIMIdentity{}, errors.New("vocat: EC20 returned no valid IMEI") + } + + homeMCC, homeMNC, err := adapter.readHomePLMN( + ctx, + deviceID, + iccid, + imsi, + ) + if err != nil { + return SIMIdentity{}, err + } + identity := SIMIdentity{ + ICCID: iccid, + IMSI: imsi, + IMEI: imei, + HomeMCC: homeMCC, + HomeMNC: homeMNC, + } + adapter.mu.Lock() + adapter.bindings[iccid] = ec20SIMBinding{ + deviceID: deviceID, + iccid: iccid, + imsi: imsi, + } + adapter.mu.Unlock() + return identity, nil +} + +// ReadSMSCenter returns the service-centre address configured by the SIM. +// AT+CSCA? is read-only and remains available while cellular RF is disabled +// for a Wi-Fi Calling session. +func (adapter *EC20Adapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) { + response, err := adapter.execute(ctx, strings.TrimSpace(deviceID), "AT+CSCA?") + if err != nil { + return "", fmt.Errorf("read EC20 SMS service centre: %w", err) + } + fields := parseCSV(valueAfterATPrefix(response, "+CSCA:")) + if len(fields) == 0 { + return "", errors.New("vocat: EC20 returned no SMS service-centre address") + } + value := strings.Trim(strings.TrimSpace(fields[0]), `"`) + digits := strings.TrimPrefix(value, "+") + if !validDigits(digits, 3, 20) { + return "", errors.New("vocat: EC20 returned an invalid SMS service-centre address") + } + return value, nil +} + +func (adapter *EC20Adapter) readHomePLMN( + ctx context.Context, + deviceID string, + iccid string, + imsi string, +) (string, string, error) { + mncLength, efErr := adapter.readExplicitMNCLength(ctx, deviceID) + if efErr == nil { + if len(imsi) < 3+mncLength { + return "", "", errors.New( + "vocat: IMSI is shorter than the EF_AD home PLMN", + ) + } + return imsi[:3], imsi[3 : 3+mncLength], nil + } + if adapter.options.HomePLMN != nil { + mcc, mnc, ok := adapter.options.HomePLMN(deviceID, iccid, imsi) + mcc = strings.TrimSpace(mcc) + mnc = strings.TrimSpace(mnc) + if ok && validConfiguredHomePLMN(imsi, mcc, mnc) { + return mcc, mnc, nil + } + } + // Exact assigned HPLMN prefixes are data, not an MNC-length heuristic. The + // target Vodafone UK SIM is 234/15. Unknown assignments remain fail-closed. + for prefix, mncLength := range map[string]int{"23415": 2} { + if strings.HasPrefix(imsi, prefix) { + return imsi[:3], imsi[3 : 3+mncLength], nil + } + } + return "", "", efErr +} + +func validConfiguredHomePLMN(imsi, mcc, mnc string) bool { + if !validDigits(mcc, 3, 3) || !validDigits(mnc, 2, 3) { + return false + } + return strings.HasPrefix(imsi, mcc+mnc) +} + +func (adapter *EC20Adapter) readExplicitMNCLength( + ctx context.Context, + deviceID string, +) (int, error) { + commands := []string{ + fmt.Sprintf("AT+CRSM=176,%d,0,0,4", efADDecimal), + fmt.Sprintf("AT+CRSM=176,%d,0,0,0", efADDecimal), + } + var lastErr error + for _, command := range commands { + response, err := adapter.execute(ctx, deviceID, command) + if err != nil { + lastErr = err + continue + } + data, err := parseCRSMData(response) + if err != nil { + lastErr = err + continue + } + if len(data) < 4 { + lastErr = ErrEC20MNCUnavailable + continue + } + length := int(data[3] & 0x0f) + if length == 2 || length == 3 { + return length, nil + } + lastErr = ErrEC20MNCUnavailable + } + if lastErr == nil { + lastErr = ErrEC20MNCUnavailable + } + return 0, fmt.Errorf("%w: %v", ErrEC20MNCUnavailable, lastErr) +} + +func (adapter *EC20Adapter) readICCID( + ctx context.Context, + deviceID string, +) (string, error) { + var lastErr error + for attempt := 0; attempt < 3; attempt++ { + for _, command := range []string{"AT+CCID", "AT+QCCID"} { + response, err := adapter.execute(ctx, deviceID, command) + if err != nil { + lastErr = err + continue + } + value := iccidIdentifier( + response, + []string{"+CCID:", "+QCCID:"}, + 18, + 22, + ) + if value != "" { + return value, nil + } + lastErr = errors.New("response contained no valid ICCID") + } + if attempt < 2 { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + } + return "", fmt.Errorf("read EC20 ICCID: %w", lastErr) +} + +func (adapter *EC20Adapter) CheckReady( + ctx context.Context, + identity SIMIdentity, +) (AKAEvidence, error) { + binding, err := adapter.bindingFor(identity) + if err != nil { + return AKAEvidence{}, err + } + if err := adapter.verifyLiveICCID(ctx, binding); err != nil { + return AKAEvidence{}, err + } + + // CCHO/CGLA/GET RESPONSE/CCHC is one UICC transaction. Serialize it + // across the adapter so periodic device refreshes or another AKA exchange + // cannot insert an APDU between a 61xx response and GET RESPONSE. + adapter.apduMu.Lock() + defer adapter.apduMu.Unlock() + + aid, application, err := adapter.discoverAKAApplication(ctx, binding.deviceID) + if err != nil { + return AKAEvidence{}, err + } + channel, err := adapter.openLogicalChannel(ctx, binding.deviceID, aid) + basicChannel := false + if err == nil { + if err := adapter.closeLogicalChannelWithCleanup( + binding.deviceID, + channel, + ); err != nil { + return AKAEvidence{}, err + } + } else { + // Several EC20 firmware branches reject CCHO/CGLA even though their + // basic-channel CSIM implementation is standards compliant. + if err := adapter.selectBasicApplication( + ctx, + binding.deviceID, + aid, + ); err != nil { + return AKAEvidence{}, errors.Join( + ErrEC20ApplicationAbsent, + err, + ) + } + basicChannel = true + } + + binding.aid = aid + binding.application = application + binding.basicChannel = basicChannel + adapter.mu.Lock() + adapter.bindings[binding.iccid] = binding + adapter.mu.Unlock() + return AKAEvidence{Ready: true, Application: application}, nil +} + +func (adapter *EC20Adapter) Authenticate( + ctx context.Context, + identity SIMIdentity, + challenge AKAChallenge, +) (AKAResult, error) { + binding, err := adapter.bindingFor(identity) + if err != nil { + return AKAResult{}, err + } + if binding.aid == "" { + if _, err := adapter.CheckReady(ctx, identity); err != nil { + return AKAResult{}, err + } + binding, err = adapter.bindingFor(identity) + if err != nil { + return AKAResult{}, err + } + } + if err := adapter.verifyLiveICCID(ctx, binding); err != nil { + return AKAResult{}, err + } + + adapter.apduMu.Lock() + defer adapter.apduMu.Unlock() + + apdu := buildUSIMAuthenticateAPDU(challenge) + var raw []byte + if binding.basicChannel { + if err := adapter.selectBasicApplication( + ctx, + binding.deviceID, + binding.aid, + ); err != nil { + return AKAResult{}, err + } + raw, err = adapter.transmitBasicAPDU( + ctx, + binding.deviceID, + apdu, + true, + ) + if err != nil { + return AKAResult{}, ErrEC20AKACommand + } + } else { + channel, err := adapter.openLogicalChannel( + ctx, + binding.deviceID, + binding.aid, + ) + if err != nil { + return AKAResult{}, err + } + var commandErr error + raw, commandErr = adapter.transmitLogicalAPDU( + ctx, + binding.deviceID, + channel, + apdu, + true, + ) + closeErr := adapter.closeLogicalChannelWithCleanup( + binding.deviceID, + channel, + ) + if commandErr != nil { + if closeErr != nil { + return AKAResult{}, errors.Join(commandErr, closeErr) + } + return AKAResult{}, commandErr + } + if closeErr != nil { + return AKAResult{}, closeErr + } + } + return parseUSIMAuthenticateResponse(raw) +} + +func buildUSIMAuthenticateAPDU(challenge AKAChallenge) []byte { + // TS 31.102 AUTHENTICATE, 3G security context (P2=0x81): + // Lc=34, then LV(RAND) and LV(AUTN), followed by Le. + apdu := make([]byte, 0, 40) + apdu = append(apdu, 0x00, 0x88, 0x00, 0x81, 0x22, 0x10) + apdu = append(apdu, challenge.RAND[:]...) + apdu = append(apdu, 0x10) + apdu = append(apdu, challenge.AUTN[:]...) + apdu = append(apdu, 0x00) + return apdu +} + +func parseUSIMAuthenticateResponse(raw []byte) (AKAResult, error) { + if len(raw) < 2 { + return AKAResult{}, fmt.Errorf( + "%w: response length %d has no status word", + ErrEC20AKAResponse, + len(raw), + ) + } + status := uint16(raw[len(raw)-2])<<8 | uint16(raw[len(raw)-1]) + body := raw[:len(raw)-2] + if status != 0x9000 { + switch status { + case 0x9862: + return AKAResult{}, ErrEC20AKAMACFailure + default: + return AKAResult{}, fmt.Errorf( + "%w: status word %04X", + ErrEC20AKAResponse, + status, + ) + } + } + if len(body) < 2 { + return AKAResult{}, fmt.Errorf( + "%w: response body length %d is too short", + ErrEC20AKAResponse, + len(body), + ) + } + tag := body[0] + value := body[1:] + + switch tag { + case 0xdb: + res, rest, ok := takeLV(value) + if !ok { + return AKAResult{}, fmt.Errorf( + "%w: malformed RES field in %d-byte success value", + ErrEC20AKAResponse, + len(value), + ) + } + if len(res) < 4 || len(res) > 16 { + return AKAResult{}, fmt.Errorf( + "%w: invalid RES length %d", + ErrEC20AKAResponse, + len(res), + ) + } + ck, rest, ok := takeLV(rest) + if !ok || len(ck) != 16 { + return AKAResult{}, fmt.Errorf( + "%w: invalid CK length %d", + ErrEC20AKAResponse, + len(ck), + ) + } + ik, rest, ok := takeLV(rest) + if !ok || len(ik) != 16 { + return AKAResult{}, fmt.Errorf( + "%w: invalid IK length %d", + ErrEC20AKAResponse, + len(ik), + ) + } + // Kc is present on many USIMs. It is not needed by EAP-AKA, but when + // present its LV still has to be structurally valid. + if len(rest) > 0 { + kc, tail, ok := takeLV(rest) + if !ok || len(kc) != 8 || len(tail) != 0 { + return AKAResult{}, fmt.Errorf( + "%w: invalid optional Kc length %d with %d trailing bytes", + ErrEC20AKAResponse, + len(kc), + len(tail), + ) + } + } + return AKAResult{ + RES: append([]byte(nil), res...), + CK: append([]byte(nil), ck...), + IK: append([]byte(nil), ik...), + }, nil + case 0xdc: + auts, tail, ok := takeLV(value) + if !ok || len(auts) != 14 || len(tail) != 0 { + return AKAResult{}, fmt.Errorf( + "%w: invalid AUTS length %d with %d trailing bytes", + ErrEC20AKAResponse, + len(auts), + len(tail), + ) + } + return AKAResult{ + AUTS: append([]byte(nil), auts...), + SynchronizationFailure: true, + }, nil + default: + return AKAResult{}, fmt.Errorf( + "%w: unsupported response tag %02X with %d value bytes", + ErrEC20AKAResponse, + tag, + len(value), + ) + } +} + +func takeLV(value []byte) (field, rest []byte, ok bool) { + if len(value) == 0 { + return nil, value, false + } + length := int(value[0]) + if length > len(value)-1 { + return nil, value, false + } + return value[1 : 1+length], value[1+length:], true +} + +func (adapter *EC20Adapter) Snapshot( + ctx context.Context, + deviceID string, +) (RadioSnapshot, error) { + mode, err := adapter.readOperatingMode(ctx, deviceID) + if err != nil { + return RadioSnapshot{}, err + } + active, err := adapter.readActiveCIDs(ctx, deviceID) + if err != nil { + return RadioSnapshot{}, err + } + adapter.mu.Lock() + adapter.checkpoints[deviceID] = ec20RadioCheckpoint{ + activeCIDs: append([]int(nil), active...), + } + adapter.mu.Unlock() + + purePolicy := false + if adapter.options.PureAirplanePolicy != nil { + purePolicy = adapter.options.PureAirplanePolicy(deviceID) + } + return RadioSnapshot{ + CellularDataEnabled: len(active) > 0, + OperatingMode: mode, + PureAirplanePolicy: purePolicy, + }, nil +} + +func (adapter *EC20Adapter) StopCellularData( + ctx context.Context, + deviceID string, +) error { + active, err := adapter.readActiveCIDs(ctx, deviceID) + if err != nil { + return err + } + for _, cid := range active { + if _, err := adapter.execute( + ctx, + deviceID, + fmt.Sprintf("AT+CGACT=0,%d", cid), + ); err != nil { + return fmt.Errorf("deactivate EC20 PDP context %d: %w", cid, err) + } + } + remaining, err := adapter.readActiveCIDs(ctx, deviceID) + if err != nil { + return err + } + if len(remaining) != 0 { + return errors.New("vocat: EC20 cellular data remained active") + } + return nil +} + +func (adapter *EC20Adapter) EnterVoWiFiRFOff( + ctx context.Context, + deviceID string, +) error { + mode, err := adapter.readOperatingMode(ctx, deviceID) + if err != nil { + return err + } + if mode != 4 { + if _, err := adapter.execute(ctx, deviceID, "AT+CFUN=4"); err != nil { + return fmt.Errorf("enter EC20 RF-off mode: %w", err) + } + } + mode, err = adapter.readOperatingMode(ctx, deviceID) + if err != nil { + return err + } + if mode != 4 { + return fmt.Errorf("vocat: EC20 reported CFUN=%d after RF-off request", mode) + } + return nil +} + +func (adapter *EC20Adapter) Restore( + ctx context.Context, + deviceID string, + snapshot RadioSnapshot, +) error { + if snapshot.OperatingMode < 0 { + return errors.New("vocat: invalid EC20 radio snapshot") + } + currentMode, err := adapter.readOperatingMode(ctx, deviceID) + if err != nil { + return err + } + if currentMode != snapshot.OperatingMode { + if _, err := adapter.execute( + ctx, + deviceID, + fmt.Sprintf("AT+CFUN=%d", snapshot.OperatingMode), + ); err != nil { + return fmt.Errorf("restore EC20 operating mode: %w", err) + } + } + currentMode, err = adapter.readOperatingMode(ctx, deviceID) + if err != nil { + return err + } + if currentMode != snapshot.OperatingMode { + return fmt.Errorf( + "vocat: EC20 restore reported CFUN=%d, expected %d", + currentMode, + snapshot.OperatingMode, + ) + } + + adapter.mu.Lock() + checkpoint, found := adapter.checkpoints[deviceID] + adapter.mu.Unlock() + if !found { + if snapshot.CellularDataEnabled { + return errors.New("vocat: EC20 PDP restore evidence is unavailable") + } + checkpoint.activeCIDs = nil + } + if (snapshot.OperatingMode == 0 || snapshot.OperatingMode == 4) && + len(checkpoint.activeCIDs) > 0 { + return errors.New("vocat: EC20 snapshot has active data in RF-off mode") + } + desiredCIDs := checkpoint.activeCIDs + if !adapter.options.RestoreCellularData { + desiredCIDs = nil + } + if err := adapter.reconcileActiveCIDs( + ctx, + deviceID, + desiredCIDs, + ); err != nil { + return err + } + adapter.mu.Lock() + delete(adapter.checkpoints, deviceID) + adapter.mu.Unlock() + return nil +} + +func (adapter *EC20Adapter) reconcileActiveCIDs( + ctx context.Context, + deviceID string, + desired []int, +) error { + current, err := adapter.readActiveCIDs(ctx, deviceID) + if err != nil { + return err + } + desiredSet := integerSet(desired) + currentSet := integerSet(current) + for _, cid := range current { + if _, wanted := desiredSet[cid]; wanted { + continue + } + if _, err := adapter.execute( + ctx, + deviceID, + fmt.Sprintf("AT+CGACT=0,%d", cid), + ); err != nil { + return fmt.Errorf("restore EC20 PDP context %d: %w", cid, err) + } + } + for _, cid := range desired { + if _, active := currentSet[cid]; active { + continue + } + if _, err := adapter.execute( + ctx, + deviceID, + fmt.Sprintf("AT+CGACT=1,%d", cid), + ); err != nil { + return fmt.Errorf("restore EC20 PDP context %d: %w", cid, err) + } + } + verified, err := adapter.readActiveCIDs(ctx, deviceID) + if err != nil { + return err + } + if !sameIntegers(verified, desired) { + return fmt.Errorf( + "vocat: EC20 PDP restore mismatch: active contexts %v", + verified, + ) + } + return nil +} + +func (adapter *EC20Adapter) readOperatingMode( + ctx context.Context, + deviceID string, +) (int, error) { + response, err := adapter.execute(ctx, deviceID, "AT+CFUN?") + if err != nil { + return 0, fmt.Errorf("read EC20 operating mode: %w", err) + } + value := valueAfterATPrefix(response, "+CFUN:") + fields := parseCSV(value) + if len(fields) == 0 { + return 0, errors.New("vocat: EC20 returned no CFUN mode") + } + mode, err := strconv.Atoi(fields[0]) + if err != nil || mode < 0 { + return 0, errors.New("vocat: EC20 returned an invalid CFUN mode") + } + return mode, nil +} + +func (adapter *EC20Adapter) readActiveCIDs( + ctx context.Context, + deviceID string, +) ([]int, error) { + response, err := adapter.execute(ctx, deviceID, "AT+CGACT?") + if err != nil { + return nil, fmt.Errorf("read EC20 PDP contexts: %w", err) + } + var active []int + for _, line := range response.Lines { + line = strings.TrimSpace(line) + if !strings.HasPrefix(strings.ToUpper(line), "+CGACT:") { + continue + } + fields := parseCSV(strings.TrimSpace(line[len("+CGACT:"):])) + if len(fields) < 2 { + return nil, errors.New("vocat: EC20 returned an invalid CGACT record") + } + cid, cidErr := strconv.Atoi(fields[0]) + state, stateErr := strconv.Atoi(fields[1]) + if cidErr != nil || stateErr != nil || cid <= 0 || (state != 0 && state != 1) { + return nil, errors.New("vocat: EC20 returned an invalid CGACT record") + } + if state == 1 { + active = append(active, cid) + } + } + sort.Ints(active) + return uniqueIntegers(active), nil +} + +func (adapter *EC20Adapter) bindingFor( + identity SIMIdentity, +) (ec20SIMBinding, error) { + iccid := strings.TrimSpace(identity.ICCID) + adapter.mu.Lock() + binding, ok := adapter.bindings[iccid] + adapter.mu.Unlock() + if !ok || iccid == "" { + return ec20SIMBinding{}, errors.New("vocat: EC20 SIM identity is not bound to a device") + } + if strings.TrimSpace(identity.IMSI) != binding.imsi { + return ec20SIMBinding{}, ErrEC20IdentityChanged + } + return binding, nil +} + +func (adapter *EC20Adapter) verifyLiveICCID( + ctx context.Context, + binding ec20SIMBinding, +) error { + iccid, err := adapter.readICCID(ctx, binding.deviceID) + if err != nil { + return err + } + if iccid != binding.iccid { + return ErrEC20IdentityChanged + } + return nil +} + +func (adapter *EC20Adapter) discoverAKAApplication( + ctx context.Context, + deviceID string, +) (aid string, application string, err error) { + response, cuadErr := adapter.execute(ctx, deviceID, "AT+CUAD") + if cuadErr == nil { + data, parseErr := parseCUADData(response) + if parseErr == nil { + aids := collectApplicationAIDs(data) + for _, candidate := range aids { + if strings.HasPrefix(candidate, usimAIDPrefix) { + return candidate, "USIM", nil + } + } + for _, candidate := range aids { + if strings.HasPrefix(candidate, isimAIDPrefix) { + return candidate, "ISIM", nil + } + } + if len(aids) > 0 { + return "", "", ErrEC20ApplicationAbsent + } + } + } + + // AT+CUAD is optional on older EC20 firmware. CCHO still provides a + // standards-based, evidence-bearing probe of the assigned USIM AID. + return usimAIDPrefix, "USIM", nil +} + +func (adapter *EC20Adapter) openLogicalChannel( + ctx context.Context, + deviceID string, + aid string, +) (int, error) { + response, err := adapter.execute( + ctx, + deviceID, + fmt.Sprintf(`AT+CCHO="%s"`, aid), + ) + if err != nil { + return 0, fmt.Errorf("%w: open application", ErrEC20ApplicationAbsent) + } + value := valueAfterATPrefix(response, "+CCHO:") + if value == "" { + for _, line := range response.Lines { + line = strings.TrimSpace(line) + if _, parseErr := strconv.Atoi(line); parseErr == nil { + value = line + break + } + } + } + channel, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || channel < 1 || channel > 19 { + return 0, errors.New("vocat: EC20 returned an invalid logical channel") + } + return channel, nil +} + +func (adapter *EC20Adapter) closeLogicalChannel( + ctx context.Context, + deviceID string, + channel int, +) error { + if _, err := adapter.execute( + ctx, + deviceID, + fmt.Sprintf("AT+CCHC=%d", channel), + ); err != nil { + return fmt.Errorf("close EC20 logical channel: %w", err) + } + return nil +} + +func (adapter *EC20Adapter) closeLogicalChannelWithCleanup( + deviceID string, + channel int, +) error { + ctx, cancel := context.WithTimeout( + context.Background(), + channelCleanupTimeout, + ) + defer cancel() + return adapter.closeLogicalChannel(ctx, deviceID, channel) +} + +func (adapter *EC20Adapter) selectBasicApplication( + ctx context.Context, + deviceID string, + aid string, +) error { + aidBytes, err := hex.DecodeString(aid) + if err != nil || len(aidBytes) == 0 || len(aidBytes) > 255 { + return errors.New("vocat: invalid USIM application identifier") + } + // SELECT by DF name, request the first/only matching application and FCP. + apdu := []byte{0x00, 0xa4, 0x04, 0x04, byte(len(aidBytes))} + apdu = append(apdu, aidBytes...) + raw, err := adapter.transmitBasicAPDU(ctx, deviceID, apdu, false) + if err != nil { + return fmt.Errorf("select EC20 basic-channel application: %w", err) + } + _, status, err := splitAPDUStatus(raw) + if err != nil { + return err + } + if status != 0x9000 { + return fmt.Errorf( + "vocat: EC20 basic-channel SELECT returned %04X", + status, + ) + } + return nil +} + +func (adapter *EC20Adapter) transmitBasicAPDU( + ctx context.Context, + deviceID string, + apdu []byte, + sensitive bool, +) ([]byte, error) { + if len(apdu) == 0 || len(apdu) > 261 { + return nil, errors.New("vocat: invalid EC20 APDU length") + } + var collected []byte + current := append([]byte(nil), apdu...) + for exchange := 0; exchange < 4; exchange++ { + command := fmt.Sprintf( + `AT+CSIM=%d,"%s"`, + len(current)*2, + strings.ToUpper(hex.EncodeToString(current)), + ) + var response modem.Response + var err error + if sensitive { + response, err = adapter.executeSensitive(ctx, deviceID, command) + } else { + response, err = adapter.execute(ctx, deviceID, command) + } + if err != nil { + return nil, errors.New("vocat: EC20 CSIM exchange failed") + } + raw, err := parseCSIMData(response) + if err != nil { + return nil, err + } + body, status, err := splitAPDUStatus(raw) + if err != nil { + return nil, err + } + collected = append(collected, body...) + sw1 := byte(status >> 8) + if sw1 != 0x61 && sw1 != 0x9f { + collected = append(collected, byte(status>>8), byte(status)) + return collected, nil + } + // GET RESPONSE on the same basic channel. Le=0 means 256 bytes. + current = []byte{0x00, 0xc0, 0x00, 0x00, byte(status)} + } + return nil, errors.New("vocat: EC20 APDU response chaining exceeded limit") +} + +func (adapter *EC20Adapter) transmitLogicalAPDU( + ctx context.Context, + deviceID string, + channel int, + apdu []byte, + sensitive bool, +) ([]byte, error) { + if channel < 1 || channel > 19 || len(apdu) == 0 || len(apdu) > 261 { + return nil, ErrEC20AKACommand + } + var collected []byte + current := append([]byte(nil), apdu...) + for exchange := 0; exchange < 4; exchange++ { + command := fmt.Sprintf( + `AT+CGLA=%d,%d,"%s"`, + channel, + len(current)*2, + strings.ToUpper(hex.EncodeToString(current)), + ) + var response modem.Response + var err error + if sensitive { + response, err = adapter.executeSensitive(ctx, deviceID, command) + } else { + response, err = adapter.execute(ctx, deviceID, command) + } + if err != nil { + return nil, errors.Join(ErrEC20AKACommand, err) + } + raw, err := parseCGLAData(response) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrEC20AKAResponse, err) + } + body, status, err := splitAPDUStatus(raw) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrEC20AKAResponse, err) + } + collected = append(collected, body...) + sw1 := byte(status >> 8) + if sw1 != 0x61 && sw1 != 0x9f { + collected = append(collected, byte(status>>8), byte(status)) + return collected, nil + } + // CGLA carries the logical-channel identifier separately, so GET + // RESPONSE retains the same interindustry CLA used by AUTHENTICATE. + // Le=0 means 256 bytes when SW2 is zero. + current = []byte{0x00, 0xc0, 0x00, 0x00, byte(status)} + } + return nil, fmt.Errorf( + "%w: logical-channel response chaining exceeded limit", + ErrEC20AKAResponse, + ) +} + +func splitAPDUStatus(raw []byte) ([]byte, uint16, error) { + if len(raw) < 2 { + return nil, 0, errors.New("vocat: EC20 APDU has no status word") + } + status := uint16(raw[len(raw)-2])<<8 | uint16(raw[len(raw)-1]) + return raw[:len(raw)-2], status, nil +} + +func (adapter *EC20Adapter) execute( + ctx context.Context, + deviceID string, + command string, +) (modem.Response, error) { + return adapter.executor.ExecuteAT(ctx, deviceID, command) +} + +func (adapter *EC20Adapter) executeSensitive( + ctx context.Context, + deviceID string, + command string, +) (modem.Response, error) { + if executor, ok := adapter.executor.(EC20SensitiveATExecutor); ok { + return executor.ExecuteSensitiveAT(ctx, deviceID, command) + } + return adapter.executor.ExecuteAT(ctx, deviceID, command) +} + +func parseCRSMData(response modem.Response) ([]byte, error) { + value := valueAfterATPrefix(response, "+CRSM:") + fields := parseCSV(value) + if len(fields) < 2 { + return nil, errors.New("invalid CRSM response") + } + sw1, err1 := strconv.Atoi(fields[0]) + sw2, err2 := strconv.Atoi(fields[1]) + if err1 != nil || err2 != nil { + return nil, errors.New("invalid CRSM status") + } + if sw1 != 0x90 && sw1 != 0x91 && sw1 != 0x9f { + return nil, fmt.Errorf("CRSM status %02X%02X", sw1, sw2) + } + if len(fields) < 3 { + return nil, errors.New("CRSM response has no data") + } + data, err := hex.DecodeString(strings.Trim(fields[2], `"`)) + if err != nil { + return nil, errors.New("CRSM response data is not hexadecimal") + } + return data, nil +} + +func parseCUADData(response modem.Response) ([]byte, error) { + fields := parseCSV(valueAfterATPrefix(response, "+CUAD:")) + if len(fields) == 0 { + return nil, errors.New("CUAD response has no data") + } + value := fields[len(fields)-1] + data, err := hex.DecodeString(strings.Trim(value, `"`)) + if err != nil || len(data) == 0 { + return nil, errors.New("CUAD response data is invalid") + } + if len(data) >= 2 && data[len(data)-2] == 0x90 && data[len(data)-1] == 0x00 { + data = data[:len(data)-2] + } + return data, nil +} + +func collectApplicationAIDs(data []byte) []string { + var result []string + var walk func([]byte) + walk = func(value []byte) { + for len(value) > 0 { + tag, constructed, body, consumed, err := decodeBERTLV(value) + if err != nil || consumed == 0 { + return + } + if len(tag) == 1 && tag[0] == 0x4f && len(body) > 0 { + result = append(result, strings.ToUpper(hex.EncodeToString(body))) + } + if constructed { + walk(body) + } + value = value[consumed:] + } + } + walk(data) + return result +} + +func decodeBERTLV(data []byte) ( + tag []byte, + constructed bool, + value []byte, + consumed int, + err error, +) { + if len(data) < 2 { + return nil, false, nil, 0, errors.New("short BER-TLV") + } + tagLength := 1 + if data[0]&0x1f == 0x1f { + for { + if tagLength >= len(data) { + return nil, false, nil, 0, errors.New("short BER tag") + } + last := data[tagLength]&0x80 == 0 + tagLength++ + if last { + break + } + if tagLength > 4 { + return nil, false, nil, 0, errors.New("oversized BER tag") + } + } + } + body, bodyConsumed, err := decodeBERTLVValue(data[tagLength:]) + if err != nil { + return nil, false, nil, 0, err + } + return data[:tagLength], + data[0]&0x20 != 0, + body, + tagLength + bodyConsumed, + nil +} + +func decodeBERTLVValue(data []byte) ([]byte, int, error) { + if len(data) == 0 { + return nil, 0, errors.New("missing BER length") + } + length := int(data[0]) + lengthOctets := 1 + if data[0]&0x80 != 0 { + count := int(data[0] & 0x7f) + if count == 0 || count > 2 || len(data) < 1+count { + return nil, 0, errors.New("invalid BER length") + } + length = 0 + for _, octet := range data[1 : 1+count] { + length = length<<8 | int(octet) + } + lengthOctets += count + } + if length > len(data)-lengthOctets { + return nil, 0, errors.New("short BER value") + } + return data[lengthOctets : lengthOctets+length], + lengthOctets + length, + nil +} + +func parseCGLAData(response modem.Response) ([]byte, error) { + return parseATAPDUData(response, "+CGLA:") +} + +func parseCSIMData(response modem.Response) ([]byte, error) { + return parseATAPDUData(response, "+CSIM:") +} + +func parseATAPDUData(response modem.Response, prefix string) ([]byte, error) { + fields := parseCSV(valueAfterATPrefix(response, prefix)) + if len(fields) < 2 { + return nil, errors.New("EC20 response has no APDU") + } + declared, err := strconv.Atoi(fields[0]) + if err != nil || declared < 0 { + return nil, errors.New("EC20 response has invalid APDU length") + } + encoded := strings.Trim(fields[1], `" `) + data, err := hex.DecodeString(encoded) + if err != nil { + return nil, errors.New("EC20 APDU response is not hexadecimal") + } + if declared != len(encoded) && declared != len(data) { + return nil, errors.New("EC20 APDU response length mismatch") + } + return data, nil +} + +func responseContainsValue( + response modem.Response, + prefix string, + expected string, +) bool { + return strings.EqualFold( + strings.Trim(strings.TrimSpace(valueAfterATPrefix(response, prefix)), `"`), + expected, + ) +} + +func valueAfterATPrefix(response modem.Response, prefix string) string { + for _, line := range response.Lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(strings.ToUpper(line), strings.ToUpper(prefix)) { + return strings.TrimSpace(line[len(prefix):]) + } + } + return "" +} + +func digitIdentifier( + response modem.Response, + prefixes []string, + minimum int, + maximum int, +) string { + for _, prefix := range prefixes { + value := strings.Trim(valueAfterATPrefix(response, prefix), `" `) + if validDigits(value, minimum, maximum) { + return value + } + } + for _, line := range response.Lines { + value := strings.TrimSpace(line) + if validDigits(value, minimum, maximum) { + return value + } + } + return "" +} + +func iccidIdentifier( + response modem.Response, + prefixes []string, + minimum int, + maximum int, +) string { + normalize := func(value string) string { + value = strings.Trim(value, `" `) + value = strings.TrimRight(value, "Ff") + if validDigits(value, minimum, maximum) { + return value + } + return "" + } + for _, prefix := range prefixes { + if value := normalize(valueAfterATPrefix(response, prefix)); value != "" { + return value + } + } + for _, line := range response.Lines { + if value := normalize(strings.TrimSpace(line)); value != "" { + return value + } + } + return "" +} + +func validDigits(value string, minimum int, maximum int) bool { + if len(value) < minimum || len(value) > maximum { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} + +func parseCSV(value string) []string { + reader := csv.NewReader(strings.NewReader(value)) + reader.TrimLeadingSpace = true + reader.LazyQuotes = true + record, err := reader.Read() + if err != nil && err != io.EOF { + return nil + } + for index := range record { + record[index] = strings.TrimSpace(record[index]) + } + return record +} + +func integerSet(values []int) map[int]struct{} { + result := make(map[int]struct{}, len(values)) + for _, value := range values { + result[value] = struct{}{} + } + return result +} + +func uniqueIntegers(values []int) []int { + if len(values) < 2 { + return values + } + result := values[:1] + for _, value := range values[1:] { + if value != result[len(result)-1] { + result = append(result, value) + } + } + return result +} + +func sameIntegers(left, right []int) bool { + left = append([]int(nil), left...) + right = append([]int(nil), right...) + sort.Ints(left) + sort.Ints(right) + left = uniqueIntegers(left) + right = uniqueIntegers(right) + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} diff --git a/internal/vowifi/ec20_adapter_test.go b/internal/vowifi/ec20_adapter_test.go new file mode 100644 index 0000000..21d5149 --- /dev/null +++ b/internal/vowifi/ec20_adapter_test.go @@ -0,0 +1,588 @@ +package vowifi + +import ( + "bytes" + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "vocat/internal/modem" +) + +type ec20TranscriptStep struct { + command string + sensitive bool + lines []string + final string + err error +} + +type ec20Transcript struct { + t *testing.T + mu sync.Mutex + steps []ec20TranscriptStep + next int +} + +func TestICCIDIdentifierStripsBCDPadding(t *testing.T) { + for _, test := range []struct { + wire string + want string + }{ + {wire: "8944110069353447454F", want: "8944110069353447454"}, + {wire: "894921007608519523FF", want: "894921007608519523"}, + } { + response := modem.Response{Lines: []string{"+QCCID: " + test.wire}} + if got := iccidIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22); got != test.want { + t.Fatalf("iccidIdentifier(%q) = %q, want %q", test.wire, got, test.want) + } + } +} + +func (transcript *ec20Transcript) ExecuteAT( + _ context.Context, + _ string, + command string, +) (modem.Response, error) { + return transcript.execute(command, false) +} + +func (transcript *ec20Transcript) ExecuteSensitiveAT( + _ context.Context, + _ string, + command string, +) (modem.Response, error) { + return transcript.execute(command, true) +} + +func (transcript *ec20Transcript) execute( + command string, + sensitive bool, +) (modem.Response, error) { + transcript.t.Helper() + transcript.mu.Lock() + defer transcript.mu.Unlock() + if transcript.next >= len(transcript.steps) { + transcript.t.Fatalf("unexpected EC20 command %q", command) + } + step := transcript.steps[transcript.next] + transcript.next++ + if command != step.command { + transcript.t.Fatalf( + "EC20 command %d = %q, want %q", + transcript.next, + command, + step.command, + ) + } + if sensitive != step.sensitive { + transcript.t.Fatalf( + "EC20 command %q sensitive=%v, want %v", + command, + sensitive, + step.sensitive, + ) + } + final := step.final + if final == "" && step.err == nil { + final = "OK" + } + return modem.Response{ + Command: command, + Lines: append([]string(nil), step.lines...), + Final: final, + }, step.err +} + +func (transcript *ec20Transcript) assertDone() { + transcript.t.Helper() + transcript.mu.Lock() + defer transcript.mu.Unlock() + if transcript.next != len(transcript.steps) { + transcript.t.Fatalf( + "consumed %d/%d EC20 transcript steps", + transcript.next, + len(transcript.steps), + ) + } +} + +func TestEC20AdapterCSIMFallbackSupportsSuccessAndSynchronizationFailure( + t *testing.T, +) { + t.Parallel() + + tests := []struct { + name string + apdu []byte + wantErr error + assert func(*testing.T, AKAResult) + }{ + { + name: "success", + apdu: successfulUSIMResponse(), + assert: func(t *testing.T, result AKAResult) { + t.Helper() + if result.SynchronizationFailure { + t.Fatal("successful result was marked as synchronization failure") + } + if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) { + t.Fatalf("RES = %x", result.RES) + } + if len(result.CK) != 16 || len(result.IK) != 16 { + t.Fatalf("CK/IK lengths = %d/%d", len(result.CK), len(result.IK)) + } + }, + }, + { + name: "synchronization_failure", + apdu: synchronizationFailureUSIMResponse(), + assert: func(t *testing.T, result AKAResult) { + t.Helper() + if !result.SynchronizationFailure { + t.Fatal("AUTS result was not marked as synchronization failure") + } + if len(result.AUTS) != 14 { + t.Fatalf("AUTS length = %d", len(result.AUTS)) + } + if len(result.RES) != 0 || len(result.CK) != 0 || len(result.IK) != 0 { + t.Fatal("synchronization failure exposed a success vector") + } + }, + }, + { + name: "mac_failure_9862", + apdu: []byte{0x98, 0x62}, + wantErr: ErrEC20AKAMACFailure, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + var challenge AKAChallenge + for index := range challenge.RAND { + challenge.RAND[index] = byte(index) + challenge.AUTN[index] = byte(0xf0 + index) + } + authAPDU := buildUSIMAuthenticateAPDU(challenge) + authCommand := fmt.Sprintf( + `AT+CSIM=%d,"%s"`, + len(authAPDU)*2, + strings.ToUpper(hex.EncodeToString(authAPDU)), + ) + encodedResponse := strings.ToUpper(hex.EncodeToString(test.apdu)) + + transcript := &ec20Transcript{ + t: t, + steps: append( + identityTranscriptSteps("234150123456789"), + ec20TranscriptStep{ + command: "AT+CCID", + lines: []string{"+CCID: 8944101234567890123"}, + }, + ec20TranscriptStep{ + command: "AT+CUAD", + lines: []string{ + `+CUAD: 22,"61094F07A0000000871002"`, + }, + }, + ec20TranscriptStep{ + command: `AT+CCHO="A0000000871002"`, + err: errors.New("unsupported"), + final: "ERROR", + }, + // The SELECT response requests GET RESPONSE. This is the + // behavior observed on EC20 basic-channel firmware. + ec20TranscriptStep{ + command: `AT+CSIM=24,"00A4040407A0000000871002"`, + lines: []string{`+CSIM: 4,"613A"`}, + }, + ec20TranscriptStep{ + command: `AT+CSIM=10,"00C000003A"`, + lines: []string{`+CSIM: 4,"9000"`}, + }, + ec20TranscriptStep{ + command: "AT+CCID", + lines: []string{"+CCID: 8944101234567890123"}, + }, + ec20TranscriptStep{ + command: `AT+CSIM=24,"00A4040407A0000000871002"`, + lines: []string{`+CSIM: 4,"9000"`}, + }, + ec20TranscriptStep{ + command: authCommand, + sensitive: true, + lines: []string{fmt.Sprintf( + `+CSIM: %d,"%s"`, + len(encodedResponse), + encodedResponse, + )}, + }, + ), + } + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) + if err != nil { + t.Fatal(err) + } + identity, err := adapter.ReadIdentity(context.Background(), "ec20-1") + if err != nil { + t.Fatalf("ReadIdentity: %v", err) + } + evidence, err := adapter.CheckReady(context.Background(), identity) + if err != nil { + t.Fatalf("CheckReady: %v", err) + } + if !evidence.Ready || evidence.Application != "USIM" { + t.Fatalf("AKA evidence = %#v", evidence) + } + result, err := adapter.Authenticate( + context.Background(), + identity, + challenge, + ) + if test.wantErr != nil { + if !errors.Is(err, test.wantErr) { + t.Fatalf("Authenticate error = %v, want %v", err, test.wantErr) + } + transcript.assertDone() + return + } + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + test.assert(t, result) + transcript.assertDone() + }) + } +} + +func TestEC20AdapterLogicalChannelAuthenticateFollowsGetResponse( + t *testing.T, +) { + t.Parallel() + var challenge AKAChallenge + for index := range challenge.RAND { + challenge.RAND[index] = byte(index) + challenge.AUTN[index] = byte(0xf0 + index) + } + authAPDU := buildUSIMAuthenticateAPDU(challenge) + authCommand := fmt.Sprintf( + `AT+CGLA=1,%d,"%s"`, + len(authAPDU)*2, + strings.ToUpper(hex.EncodeToString(authAPDU)), + ) + chainedResponse := logicalChainedUSIMResponse() + if len(chainedResponse)-2 != 0x35 { + t.Fatalf( + "test response body length = %d, want 0x35", + len(chainedResponse)-2, + ) + } + encodedResponse := strings.ToUpper(hex.EncodeToString(chainedResponse)) + + transcript := &ec20Transcript{ + t: t, + steps: append( + identityTranscriptSteps("234150123456789"), + ec20TranscriptStep{ + command: "AT+CCID", + lines: []string{"+CCID: 8944101234567890123"}, + }, + ec20TranscriptStep{ + command: "AT+CUAD", + lines: []string{ + `+CUAD: 22,"61094F07A0000000871002"`, + }, + }, + ec20TranscriptStep{ + command: `AT+CCHO="A0000000871002"`, + lines: []string{"+CCHO: 1"}, + }, + ec20TranscriptStep{command: "AT+CCHC=1"}, + ec20TranscriptStep{ + command: "AT+CCID", + lines: []string{"+CCID: 8944101234567890123"}, + }, + ec20TranscriptStep{ + command: `AT+CCHO="A0000000871002"`, + lines: []string{"+CCHO: 1"}, + }, + ec20TranscriptStep{ + command: authCommand, + sensitive: true, + lines: []string{`+CGLA: 4,"6135"`}, + }, + ec20TranscriptStep{ + command: `AT+CGLA=1,10,"00C0000035"`, + sensitive: true, + lines: []string{fmt.Sprintf( + `+CGLA: %d,"%s"`, + len(encodedResponse), + encodedResponse, + )}, + }, + ec20TranscriptStep{command: "AT+CCHC=1"}, + ), + } + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) + if err != nil { + t.Fatal(err) + } + identity, err := adapter.ReadIdentity(context.Background(), "ec20-1") + if err != nil { + t.Fatalf("ReadIdentity: %v", err) + } + if _, err := adapter.CheckReady(context.Background(), identity); err != nil { + t.Fatalf("CheckReady: %v", err) + } + result, err := adapter.Authenticate( + context.Background(), + identity, + challenge, + ) + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if !bytes.Equal(result.RES, []byte{1, 2, 3, 4, 5, 6, 7, 8}) || + len(result.CK) != 16 || + len(result.IK) != 16 { + t.Fatalf( + "AKA result RES=%x CK=%d IK=%d", + result.RES, + len(result.CK), + len(result.IK), + ) + } + transcript.assertDone() +} + +func TestEC20AdapterReadsExplicitHomePLMNAndKnownAssignmentFallback( + t *testing.T, +) { + t.Parallel() + tests := []struct { + name string + steps []ec20TranscriptStep + }{ + { + name: "EF_AD", + steps: identityTranscriptSteps("234150123456789"), + }, + { + name: "assigned HPLMN when EF_AD omits MNC length", + steps: append( + identityTranscriptStepsWithoutEFAD("234150123456789"), + ec20TranscriptStep{ + command: "AT+CRSM=176,28589,0,0,4", + err: errors.New("not available"), + final: "ERROR", + }, + ec20TranscriptStep{ + command: "AT+CRSM=176,28589,0,0,0", + err: errors.New("not available"), + final: "ERROR", + }, + ), + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + transcript := &ec20Transcript{t: t, steps: test.steps} + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) + if err != nil { + t.Fatal(err) + } + identity, err := adapter.ReadIdentity(context.Background(), "ec20-1") + if err != nil { + t.Fatalf("ReadIdentity: %v", err) + } + if identity.HomeMCC != "234" || identity.HomeMNC != "15" { + t.Fatalf( + "home PLMN = %s/%s, want 234/15", + identity.HomeMCC, + identity.HomeMNC, + ) + } + transcript.assertDone() + }) + } +} + +func TestEC20AdapterRadioTransactionRestoresCFUNAndPDPContexts( + t *testing.T, +) { + t.Parallel() + transcript := &ec20Transcript{ + t: t, + steps: []ec20TranscriptStep{ + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + { + command: "AT+CGACT?", + lines: []string{"+CGACT: 1,1", "+CGACT: 2,0"}, + }, + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + {command: "AT+CFUN=4"}, + {command: "AT+CFUN?", lines: []string{"+CFUN: 4"}}, + { + command: "AT+CGACT?", + lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"}, + }, + { + command: "AT+CGACT?", + lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"}, + }, + {command: "AT+CFUN?", lines: []string{"+CFUN: 4"}}, + {command: "AT+CFUN=1"}, + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + { + command: "AT+CGACT?", + lines: []string{"+CGACT: 1,0", "+CGACT: 2,0"}, + }, + {command: "AT+CGACT=1,1"}, + { + command: "AT+CGACT?", + lines: []string{"+CGACT: 1,1", "+CGACT: 2,0"}, + }, + }, + } + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{ + PureAirplanePolicy: func(string) bool { return true }, + RestoreCellularData: true, + }) + if err != nil { + t.Fatal(err) + } + snapshot, err := adapter.Snapshot(context.Background(), "ec20-1") + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if !snapshot.CellularDataEnabled || + snapshot.OperatingMode != 1 || + !snapshot.PureAirplanePolicy { + t.Fatalf("snapshot = %#v", snapshot) + } + if err := adapter.EnterVoWiFiRFOff( + context.Background(), + "ec20-1", + ); err != nil { + t.Fatalf("EnterVoWiFiRFOff: %v", err) + } + if err := adapter.StopCellularData( + context.Background(), + "ec20-1", + ); err != nil { + t.Fatalf("StopCellularData: %v", err) + } + if err := adapter.Restore( + context.Background(), + "ec20-1", + snapshot, + ); err != nil { + t.Fatalf("Restore: %v", err) + } + transcript.assertDone() +} + +func TestEC20AdapterNeverStartsCellularDataByDefault(t *testing.T) { + t.Parallel() + transcript := &ec20Transcript{ + t: t, + steps: []ec20TranscriptStep{ + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + {command: "AT+CGACT?", lines: []string{"+CGACT: 1,1"}}, + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + {command: "AT+CFUN?", lines: []string{"+CFUN: 1"}}, + {command: "AT+CGACT?", lines: []string{"+CGACT: 1,1"}}, + {command: "AT+CGACT=0,1"}, + {command: "AT+CGACT?", lines: []string{"+CGACT: 1,0"}}, + }, + } + adapter, err := NewEC20Adapter(transcript, EC20AdapterOptions{}) + if err != nil { + t.Fatal(err) + } + snapshot, err := adapter.Snapshot(context.Background(), "ec20-1") + if err != nil { + t.Fatalf("Snapshot: %v", err) + } + if err := adapter.Restore(context.Background(), "ec20-1", snapshot); err != nil { + t.Fatalf("Restore: %v", err) + } + transcript.assertDone() +} + +func identityTranscriptSteps(imsi string) []ec20TranscriptStep { + return append( + identityTranscriptStepsWithoutEFAD(imsi), + ec20TranscriptStep{ + command: "AT+CRSM=176,28589,0,0,4", + lines: []string{`+CRSM: 144,0,"00000002"`}, + }, + ) +} + +func identityTranscriptStepsWithoutEFAD(imsi string) []ec20TranscriptStep { + return []ec20TranscriptStep{ + {command: "AT+CPIN?", lines: []string{"+CPIN: READY"}}, + {command: "AT+CIMI", lines: []string{imsi}}, + { + command: "AT+CCID", + lines: []string{"+CCID: 8944101234567890123"}, + }, + {command: "AT+CGSN", lines: []string{"867530912345678"}}, + } +} + +func successfulUSIMResponse() []byte { + res := []byte{1, 2, 3, 4, 5, 6, 7, 8} + ck := bytes.Repeat([]byte{0x11}, 16) + ik := bytes.Repeat([]byte{0x22}, 16) + kc := bytes.Repeat([]byte{0x33}, 8) + value := []byte{byte(len(res))} + value = append(value, res...) + value = append(value, byte(len(ck))) + value = append(value, ck...) + value = append(value, byte(len(ik))) + value = append(value, ik...) + value = append(value, byte(len(kc))) + value = append(value, kc...) + raw := []byte{0xdb} + raw = append(raw, value...) + return append(raw, 0x90, 0x00) +} + +func logicalChainedUSIMResponse() []byte { + res := []byte{1, 2, 3, 4, 5, 6, 7, 8} + ck := bytes.Repeat([]byte{0x11}, 16) + ik := bytes.Repeat([]byte{0x22}, 16) + kc := bytes.Repeat([]byte{0x33}, 8) + value := []byte{byte(len(res))} + value = append(value, res...) + value = append(value, byte(len(ck))) + value = append(value, ck...) + value = append(value, byte(len(ik))) + value = append(value, ik...) + value = append(value, byte(len(kc))) + value = append(value, kc...) + raw := []byte{0xdb} + raw = append(raw, value...) + return append(raw, 0x90, 0x00) +} + +func synchronizationFailureUSIMResponse() []byte { + auts := make([]byte, 14) + for index := range auts { + auts[index] = byte(0xa0 + index) + } + raw := []byte{0xdc, byte(len(auts))} + raw = append(raw, auts...) + return append(raw, 0x90, 0x00) +} diff --git a/internal/vowifi/ike/auth.go b/internal/vowifi/ike/auth.go new file mode 100644 index 0000000..bc4f2a6 --- /dev/null +++ b/internal/vowifi/ike/auth.go @@ -0,0 +1,368 @@ +package ike + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/rsa" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "crypto/subtle" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "errors" + "fmt" + "math/big" + "strings" + + "vocat/internal/vowifi" +) + +const ( + authMethodRSASignature = 1 + authMethodSharedKeyMIC = 2 + authMethodECDSASHA256P256 = 9 + authMethodECDSASHA384P384 = 10 + authMethodECDSASHA512P521 = 11 + authMethodDigitalSignature = 14 +) + +var ( + oidSHA256WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 11} + oidSHA384WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 12} + oidSHA512WithRSA = asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 1, 13} + oidECDSAWithSHA256 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 2} + oidECDSAWithSHA384 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 3} + oidECDSAWithSHA512 = asn1.ObjectIdentifier{1, 2, 840, 10045, 4, 3, 4} +) + +func responderSignedOctets( + initialResponse []byte, + initiatorNonce []byte, + suite negotiatedSuite, + skpr []byte, + idr payload, +) ([]byte, error) { + idHash, err := prf(suite, skpr, idr.Body) + if err != nil { + return nil, err + } + signed := make([]byte, 0, len(initialResponse)+len(initiatorNonce)+len(idHash)) + signed = append(signed, initialResponse...) + signed = append(signed, initiatorNonce...) + signed = append(signed, idHash...) + return signed, nil +} + +func initiatorSignedOctets( + initialRequest []byte, + responderNonce []byte, + suite negotiatedSuite, + skpi []byte, + idi payload, +) ([]byte, error) { + idHash, err := prf(suite, skpi, idi.Body) + if err != nil { + return nil, err + } + signed := make([]byte, 0, len(initialRequest)+len(responderNonce)+len(idHash)) + signed = append(signed, initialRequest...) + signed = append(signed, responderNonce...) + signed = append(signed, idHash...) + return signed, nil +} + +func makeEAPInitiatorAUTH( + msk []byte, + initialRequest []byte, + responderNonce []byte, + suite negotiatedSuite, + skpi []byte, + idi payload, +) (payload, error) { + signed, err := initiatorSignedOctets(initialRequest, responderNonce, suite, skpi, idi) + if err != nil { + return payload{}, err + } + paddedKey, err := prf(suite, msk, []byte("Key Pad for IKEv2")) + if err != nil { + return payload{}, err + } + authValue, err := prf(suite, paddedKey, signed) + if err != nil { + return payload{}, err + } + body := make([]byte, 4+len(authValue)) + body[0] = authMethodSharedKeyMIC + copy(body[4:], authValue) + return payload{Type: payloadAuth, Body: body}, nil +} + +func verifyEAPResponderAUTH( + auth payload, + msk []byte, + initialResponse []byte, + initiatorNonce []byte, + suite negotiatedSuite, + skpr []byte, + idr payload, +) error { + if len(auth.Body) < 4 || auth.Body[0] != authMethodSharedKeyMIC { + return errors.New("ike: final responder AUTH does not use the EAP shared-key MIC") + } + signed, err := responderSignedOctets(initialResponse, initiatorNonce, suite, skpr, idr) + if err != nil { + return err + } + paddedKey, err := prf(suite, msk, []byte("Key Pad for IKEv2")) + if err != nil { + return err + } + expected, err := prf(suite, paddedKey, signed) + if err != nil { + return err + } + if len(auth.Body[4:]) != len(expected) || subtle.ConstantTimeCompare(auth.Body[4:], expected) != 1 { + return errors.New("ike: final responder AUTH is invalid") + } + return nil +} + +func validateInitialResponderAUTH( + payloads []payload, + initialResponse []byte, + initiatorNonce []byte, + suite negotiatedSuite, + skpr []byte, + expectedIDr string, + serverName string, + roots *x509.CertPool, + pinned crypto.PublicKey, + allowMissing bool, +) (vowifi.ResponderAUTHStatus, payload, error) { + idrPayloads := payloadsOfType(payloads, payloadIDr) + authPayloads := payloadsOfType(payloads, payloadAuth) + if len(authPayloads) == 0 { + if len(idrPayloads) > 1 { + return vowifi.ResponderAUTHInvalid, payload{}, errors.New("ike: duplicate responder identity payload") + } + if allowMissing { + if len(idrPayloads) == 1 { + return vowifi.ResponderAUTHMissing, idrPayloads[0], nil + } + return vowifi.ResponderAUTHMissing, payload{}, nil + } + return vowifi.ResponderAUTHMissing, payload{}, vowifi.ErrResponderAUTHRequired + } + if len(authPayloads) != 1 || len(idrPayloads) != 1 { + return vowifi.ResponderAUTHInvalid, payload{}, errors.New("ike: responder AUTH requires exactly one IDr and AUTH payload") + } + idr := idrPayloads[0] + auth := authPayloads[0] + if len(idr.Body) < 4 || len(auth.Body) < 5 { + return vowifi.ResponderAUTHInvalid, idr, errors.New("ike: responder IDr or AUTH payload is truncated") + } + if err := validateFQDNIDr(idr, expectedIDr, "initial ePDG"); err != nil { + return vowifi.ResponderAUTHInvalid, idr, err + } + publicKey := pinned + if publicKey == nil { + certificates, err := parseResponderCertificates(payloads) + if err != nil { + return vowifi.ResponderAUTHInvalid, idr, err + } + if len(certificates) == 0 { + return vowifi.ResponderAUTHInvalid, idr, errors.New("ike: responder AUTH has no certificate or pinned public key") + } + if err := verifyResponderCertificate(certificates, roots, serverName); err != nil { + return vowifi.ResponderAUTHInvalid, idr, err + } + publicKey = certificates[0].PublicKey + } + signed, err := responderSignedOctets(initialResponse, initiatorNonce, suite, skpr, idr) + if err != nil { + return vowifi.ResponderAUTHInvalid, idr, err + } + if err := verifyDigitalAUTH(publicKey, auth.Body[0], auth.Body[4:], signed); err != nil { + return vowifi.ResponderAUTHInvalid, idr, fmt.Errorf("ike: invalid responder AUTH: %w", err) + } + return vowifi.ResponderAUTHVerified, idr, nil +} + +func validateFQDNIDr(idr payload, expectedIDr string, label string) error { + if len(idr.Body) < 4 { + return errors.New("ike: responder identity is truncated") + } + identityType := idr.Body[0] + identity := strings.TrimSpace(string(idr.Body[4:])) + if identityType != 2 { + return fmt.Errorf("ike: %s IDr must use ID_FQDN, got type %d", label, identityType) + } + if identity == "" { + return fmt.Errorf("ike: %s IDr is empty", label) + } + if expectedIDr != "" && !strings.EqualFold(strings.TrimSuffix(identity, "."), strings.TrimSuffix(expectedIDr, ".")) { + return fmt.Errorf("ike: %s IDr %q does not match %q", label, identity, expectedIDr) + } + return nil +} + +func parseResponderCertificates(payloads []payload) ([]*x509.Certificate, error) { + var certificates []*x509.Certificate + for _, item := range payloadsOfType(payloads, payloadCert) { + if len(item.Body) < 2 { + return nil, errors.New("ike: responder certificate payload is truncated") + } + if item.Body[0] != 4 { + return nil, fmt.Errorf("ike: unsupported responder certificate encoding %d", item.Body[0]) + } + certificate, err := x509.ParseCertificate(item.Body[1:]) + if err != nil { + return nil, fmt.Errorf("ike: parse responder certificate: %w", err) + } + certificates = append(certificates, certificate) + } + return certificates, nil +} + +func verifyResponderCertificate(certificates []*x509.Certificate, roots *x509.CertPool, serverName string) error { + if len(certificates) == 0 { + return errors.New("ike: no responder certificate") + } + if roots == nil { + var err error + roots, err = x509.SystemCertPool() + if err != nil { + return fmt.Errorf("ike: load system certificate roots: %w", err) + } + } + intermediates := x509.NewCertPool() + for _, certificate := range certificates[1:] { + intermediates.AddCert(certificate) + } + options := x509.VerifyOptions{ + Roots: roots, + Intermediates: intermediates, + KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSName: strings.TrimSuffix(serverName, "."), + } + if _, err := certificates[0].Verify(options); err != nil { + return fmt.Errorf("ike: verify responder certificate: %w", err) + } + return nil +} + +func verifyDigitalAUTH(publicKey crypto.PublicKey, method uint8, signature, signed []byte) error { + switch method { + case authMethodRSASignature: + key, ok := publicKey.(*rsa.PublicKey) + if !ok { + return errors.New("RSA AUTH used with a non-RSA public key") + } + digest := sha1.Sum(signed) + return rsa.VerifyPKCS1v15(key, crypto.SHA1, digest[:], signature) + case authMethodECDSASHA256P256: + return verifyRawECDSA(publicKey, crypto.SHA256, signature, signed) + case authMethodECDSASHA384P384: + return verifyRawECDSA(publicKey, crypto.SHA384, signature, signed) + case authMethodECDSASHA512P521: + return verifyRawECDSA(publicKey, crypto.SHA512, signature, signed) + case authMethodDigitalSignature: + return verifyGenericSignature(publicKey, signature, signed) + default: + return fmt.Errorf("unsupported responder AUTH method %d", method) + } +} + +func verifyRawECDSA(publicKey crypto.PublicKey, algorithm crypto.Hash, signature, signed []byte) error { + key, ok := publicKey.(*ecdsa.PublicKey) + if !ok { + return errors.New("ECDSA AUTH used with a non-ECDSA public key") + } + size := (key.Curve.Params().BitSize + 7) / 8 + if len(signature) != size*2 { + return fmt.Errorf("ECDSA signature length %d does not match curve size %d", len(signature), size) + } + digest, err := hashSignedOctets(algorithm, signed) + if err != nil { + return err + } + r := new(big.Int).SetBytes(signature[:size]) + s := new(big.Int).SetBytes(signature[size:]) + if !ecdsa.Verify(key, digest, r, s) { + return errors.New("ECDSA signature verification failed") + } + return nil +} + +func verifyGenericSignature(publicKey crypto.PublicKey, encoded, signed []byte) error { + var algorithm pkix.AlgorithmIdentifier + rest, err := asn1.Unmarshal(encoded, &algorithm) + if err != nil || len(rest) == 0 { + return errors.New("generic digital signature has an invalid AlgorithmIdentifier") + } + var hashAlgorithm crypto.Hash + var isRSA bool + switch { + case algorithm.Algorithm.Equal(oidSHA256WithRSA): + hashAlgorithm, isRSA = crypto.SHA256, true + case algorithm.Algorithm.Equal(oidSHA384WithRSA): + hashAlgorithm, isRSA = crypto.SHA384, true + case algorithm.Algorithm.Equal(oidSHA512WithRSA): + hashAlgorithm, isRSA = crypto.SHA512, true + case algorithm.Algorithm.Equal(oidECDSAWithSHA256): + hashAlgorithm = crypto.SHA256 + case algorithm.Algorithm.Equal(oidECDSAWithSHA384): + hashAlgorithm = crypto.SHA384 + case algorithm.Algorithm.Equal(oidECDSAWithSHA512): + hashAlgorithm = crypto.SHA512 + default: + return fmt.Errorf("unsupported generic signature algorithm %s", algorithm.Algorithm.String()) + } + digest, err := hashSignedOctets(hashAlgorithm, signed) + if err != nil { + return err + } + if isRSA { + key, ok := publicKey.(*rsa.PublicKey) + if !ok { + return errors.New("RSA signature used with a non-RSA public key") + } + return rsa.VerifyPKCS1v15(key, hashAlgorithm, digest, rest) + } + key, ok := publicKey.(*ecdsa.PublicKey) + if !ok { + return errors.New("ECDSA signature used with a non-ECDSA public key") + } + if !ecdsa.VerifyASN1(key, digest, rest) { + return errors.New("ECDSA generic signature verification failed") + } + return nil +} + +func hashSignedOctets(algorithm crypto.Hash, signed []byte) ([]byte, error) { + switch algorithm { + case crypto.SHA1: + sum := sha1.Sum(signed) + return sum[:], nil + case crypto.SHA256: + sum := sha256.Sum256(signed) + return sum[:], nil + case crypto.SHA384: + sum := sha512.Sum384(signed) + return sum[:], nil + case crypto.SHA512: + sum := sha512.Sum512(signed) + return sum[:], nil + default: + return nil, fmt.Errorf("unsupported signature hash %v", algorithm) + } +} + +func equalPublicKeys(first, second crypto.PublicKey) bool { + firstDER, firstErr := x509.MarshalPKIXPublicKey(first) + secondDER, secondErr := x509.MarshalPKIXPublicKey(second) + return firstErr == nil && secondErr == nil && bytes.Equal(firstDER, secondDER) +} diff --git a/internal/vowifi/ike/child.go b/internal/vowifi/ike/child.go new file mode 100644 index 0000000..6d28818 --- /dev/null +++ b/internal/vowifi/ike/child.go @@ -0,0 +1,336 @@ +package ike + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "net" + + "vocat/internal/vowifi" +) + +const ( + configRequest = 1 + configReply = 2 + + configInternalIPv4Address = 1 + configInternalIPv4DNS = 3 + configInternalIPv6Address = 8 + configInternalIPv6DNS = 10 + configPCSCFIPv4Address = 20 + configPCSCFIPv6Address = 21 + + trafficSelectorIPv4Range = 7 + trafficSelectorIPv6Range = 8 +) + +type espSuite struct { + EncryptionID uint16 + EncryptionBits int + IntegrityID uint16 + ESN uint16 +} + +func (suite espSuite) encryptionKeyLength() (int, error) { + if suite.EncryptionID != encryptionAESCBC || (suite.EncryptionBits != 128 && suite.EncryptionBits != 256) { + return 0, fmt.Errorf("%w: ESP encryption id=%d bits=%d", errUnsupportedSuite, suite.EncryptionID, suite.EncryptionBits) + } + return suite.EncryptionBits / 8, nil +} + +func (suite espSuite) integrityKeyLength() (int, error) { + switch suite.IntegrityID { + case integrityHMACSHA1_96: + return 20, nil + case integrityHMACSHA256_128: + return 32, nil + default: + return 0, fmt.Errorf("%w: ESP integrity id=%d", errUnsupportedSuite, suite.IntegrityID) + } +} + +func parseESPSuite(item proposal) (espSuite, error) { + if item.Protocol != protocolESP || len(item.SPI) != 4 { + return espSuite{}, fmt.Errorf("%w: invalid ESP proposal protocol or SPI", errUnsupportedSuite) + } + var suite espSuite + seen := make(map[uint8]bool) + for _, candidate := range item.Transforms { + if seen[candidate.Type] { + return espSuite{}, fmt.Errorf("%w: duplicate ESP transform type %d", errUnsupportedSuite, candidate.Type) + } + seen[candidate.Type] = true + switch candidate.Type { + case transformEncryption: + suite.EncryptionID = candidate.ID + suite.EncryptionBits = candidate.KeyLength + case transformIntegrity: + suite.IntegrityID = candidate.ID + case transformESN: + suite.ESN = candidate.ID + default: + return espSuite{}, fmt.Errorf("%w: unsupported ESP transform type %d", errUnsupportedSuite, candidate.Type) + } + } + if _, err := suite.encryptionKeyLength(); err != nil { + return espSuite{}, err + } + if _, err := suite.integrityKeyLength(); err != nil { + return espSuite{}, err + } + if suite.ESN != 0 { + return espSuite{}, fmt.Errorf("%w: ESP extended sequence numbers are unsupported", errUnsupportedSuite) + } + return suite, nil +} + +type trafficSelector struct { + IPProtocol uint8 + StartPort uint16 + EndPort uint16 + StartIP net.IP + EndIP net.IP +} + +func anyTrafficSelector(ipv6 bool) payload { + var selector []byte + if ipv6 { + selector = make([]byte, 40) + selector[0] = trafficSelectorIPv6Range + binary.BigEndian.PutUint16(selector[2:4], uint16(len(selector))) + binary.BigEndian.PutUint16(selector[6:8], 65535) + copy(selector[8:24], net.IPv6zero) + for index := 24; index < 40; index++ { + selector[index] = 0xff + } + } else { + selector = make([]byte, 16) + selector[0] = trafficSelectorIPv4Range + binary.BigEndian.PutUint16(selector[2:4], uint16(len(selector))) + binary.BigEndian.PutUint16(selector[6:8], 65535) + copy(selector[8:12], net.IPv4zero.To4()) + copy(selector[12:16], net.IPv4bcast.To4()) + } + body := append([]byte{1, 0, 0, 0}, selector...) + return payload{Body: body} +} + +func dualStackTrafficSelectors(kind uint8) payload { + ipv4 := anyTrafficSelector(false) + ipv6 := anyTrafficSelector(true) + body := []byte{2, 0, 0, 0} + body = append(body, ipv4.Body[4:]...) + body = append(body, ipv6.Body[4:]...) + return payload{Type: kind, Body: body} +} + +func parseTrafficSelectors(item payload) ([]trafficSelector, error) { + if len(item.Body) < 4 { + return nil, errors.New("ike: traffic selector payload is truncated") + } + count := int(item.Body[0]) + offset := 4 + result := make([]trafficSelector, 0, count) + for index := 0; index < count; index++ { + if offset+8 > len(item.Body) { + return nil, errors.New("ike: traffic selector is truncated") + } + length := int(binary.BigEndian.Uint16(item.Body[offset+2 : offset+4])) + if length < 16 || offset+length > len(item.Body) { + return nil, errors.New("ike: traffic selector has an invalid length") + } + selector := trafficSelector{ + IPProtocol: item.Body[offset+1], + StartPort: binary.BigEndian.Uint16(item.Body[offset+4 : offset+6]), + EndPort: binary.BigEndian.Uint16(item.Body[offset+6 : offset+8]), + } + switch item.Body[offset] { + case trafficSelectorIPv4Range: + if length != 16 { + return nil, errors.New("ike: IPv4 traffic selector has an invalid length") + } + selector.StartIP = append(net.IP(nil), item.Body[offset+8:offset+12]...) + selector.EndIP = append(net.IP(nil), item.Body[offset+12:offset+16]...) + case trafficSelectorIPv6Range: + if length != 40 { + return nil, errors.New("ike: IPv6 traffic selector has an invalid length") + } + selector.StartIP = append(net.IP(nil), item.Body[offset+8:offset+24]...) + selector.EndIP = append(net.IP(nil), item.Body[offset+24:offset+40]...) + default: + return nil, fmt.Errorf("ike: unsupported traffic selector type %d", item.Body[offset]) + } + result = append(result, selector) + offset += length + } + if offset != len(item.Body) { + return nil, errors.New("ike: traffic selector payload has trailing bytes") + } + return result, nil +} + +type networkConfiguration struct { + LocalIPv4 net.IP + LocalIPv6 net.IP + IPv6Prefix uint8 + DNS []net.IP + PCSCF []net.IP +} + +func configurationRequest() payload { + attributes := []uint16{ + configInternalIPv4Address, + configInternalIPv6Address, + configInternalIPv4DNS, + configInternalIPv6DNS, + configPCSCFIPv4Address, + configPCSCFIPv6Address, + } + body := []byte{configRequest, 0, 0, 0} + for _, attribute := range attributes { + var header [4]byte + binary.BigEndian.PutUint16(header[0:2], attribute) + body = append(body, header[:]...) + } + return payload{Type: payloadCP, Body: body} +} + +func parseConfiguration(item payload) (networkConfiguration, error) { + if item.Type != payloadCP || len(item.Body) < 4 || item.Body[0] != configReply { + return networkConfiguration{}, errors.New("ike: missing or invalid configuration reply") + } + var configuration networkConfiguration + for offset := 4; offset < len(item.Body); { + if offset+4 > len(item.Body) { + return networkConfiguration{}, errors.New("ike: truncated configuration attribute") + } + kind := binary.BigEndian.Uint16(item.Body[offset : offset+2]) + length := int(binary.BigEndian.Uint16(item.Body[offset+2 : offset+4])) + offset += 4 + if offset+length > len(item.Body) { + return networkConfiguration{}, errors.New("ike: invalid configuration attribute length") + } + value := item.Body[offset : offset+length] + switch kind & 0x7fff { + case configInternalIPv4Address: + if length == 4 { + configuration.LocalIPv4 = append(net.IP(nil), value...) + } + case configInternalIPv6Address: + if length != 17 { + return networkConfiguration{}, errors.New("ike: INTERNAL_IP6_ADDRESS must contain 16 address bytes and one prefix byte") + } + if value[16] > 128 { + return networkConfiguration{}, errors.New("ike: INTERNAL_IP6_ADDRESS prefix exceeds 128") + } + configuration.LocalIPv6 = append(net.IP(nil), value[:16]...) + configuration.IPv6Prefix = value[16] + case configInternalIPv4DNS: + if length == 4 { + configuration.DNS = append(configuration.DNS, append(net.IP(nil), value...)) + } + case configInternalIPv6DNS: + if length == 16 { + configuration.DNS = append(configuration.DNS, append(net.IP(nil), value...)) + } + case configPCSCFIPv4Address: + if length == 4 { + configuration.PCSCF = append(configuration.PCSCF, append(net.IP(nil), value...)) + } + case configPCSCFIPv6Address: + if length == 16 { + configuration.PCSCF = append(configuration.PCSCF, append(net.IP(nil), value...)) + } + } + offset += length + } + return configuration, nil +} + +type ChildSAConfig struct { + Name string + OuterLocal net.IP + OuterRemote net.IP + InnerLocalIPv4 net.IP + InnerLocalIPv6 net.IP + InnerIPv6Prefix uint8 + PCSCF []net.IP + DNS []net.IP + InboundSPI uint32 + OutboundSPI uint32 + Encryption string + Integrity string + InboundEncKey []byte + InboundAuthKey []byte + OutboundEncKey []byte + OutboundAuthKey []byte + InitiatorSelectors []trafficSelector + ResponderSelectors []trafficSelector + UDPEncapsulation bool + ProxyMode vowifi.ProxyMode + Relay NATTPacketRelay +} + +// NATTPacketRelay carries raw ESP packets inside UDP/4500. A user-space +// CHILD_SA installer must use this relay when ProxyMode is SOCKS5; kernel +// XFRM output cannot transparently enter a SOCKS5 UDP association. +type NATTPacketRelay interface { + SendESP(context.Context, []byte) error + ReceiveESP(context.Context, []byte) (int, error) +} + +type ChildSAHandle interface { + Close(context.Context) error +} + +type DataplaneEvidence interface { + DataplaneMode() string +} + +type DataplaneFailureNotifier interface { + Failures() <-chan error +} + +type ChildSAInstaller interface { + Install(context.Context, ChildSAConfig) (ChildSAHandle, error) +} + +func deriveChildSAKeys( + ikeSuite negotiatedSuite, + childSuite espSuite, + skd []byte, + initiatorNonce []byte, + responderNonce []byte, +) (outboundEncryption, outboundIntegrity, inboundEncryption, inboundIntegrity []byte, err error) { + encryptionLength, err := childSuite.encryptionKeyLength() + if err != nil { + return nil, nil, nil, nil, err + } + integrityLength, err := childSuite.integrityKeyLength() + if err != nil { + return nil, nil, nil, nil, err + } + seed := append(append([]byte(nil), initiatorNonce...), responderNonce...) + stream, err := prfPlus(ikeSuite, skd, seed, 2*(encryptionLength+integrityLength)) + if err != nil { + return nil, nil, nil, nil, err + } + take := func(length int) []byte { + value := append([]byte(nil), stream[:length]...) + stream = stream[length:] + return value + } + return take(encryptionLength), take(integrityLength), take(encryptionLength), take(integrityLength), nil +} + +func espSuiteNames(suite espSuite) (encryption string, integrity string) { + encryption = fmt.Sprintf("aes-cbc-%d", suite.EncryptionBits) + switch suite.IntegrityID { + case integrityHMACSHA1_96: + integrity = "hmac-sha1-96" + case integrityHMACSHA256_128: + integrity = "hmac-sha2-256-128" + } + return encryption, integrity +} diff --git a/internal/vowifi/ike/crypto.go b/internal/vowifi/ike/crypto.go new file mode 100644 index 0000000..f91a43d --- /dev/null +++ b/internal/vowifi/ike/crypto.go @@ -0,0 +1,376 @@ +package ike + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "hash" + "io" + "math/big" + "net" +) + +type ikeKeys struct { + SKd []byte + SKai []byte + SKar []byte + SKei []byte + SKer []byte + SKpi []byte + SKpr []byte +} + +func (suite negotiatedSuite) prf() (func() hash.Hash, int, error) { + switch suite.PRFID { + case prfHMACSHA1: + return sha1.New, sha1.Size, nil + case prfHMACSHA256: + return sha256.New, sha256.Size, nil + default: + return nil, 0, fmt.Errorf("%w: PRF id %d", errUnsupportedSuite, suite.PRFID) + } +} + +func (suite negotiatedSuite) encryptionKeyLength() (int, error) { + switch suite.EncryptionBits { + case 128, 256: + return suite.EncryptionBits / 8, nil + default: + return 0, fmt.Errorf("%w: AES key length %d", errUnsupportedSuite, suite.EncryptionBits) + } +} + +func (suite negotiatedSuite) integrityLengths() (keyLength int, checksumLength int, err error) { + switch suite.IntegrityID { + case integrityHMACSHA1_96: + return sha1.Size, 12, nil + case integrityHMACSHA256_128: + return sha256.Size, 16, nil + default: + return 0, 0, fmt.Errorf("%w: integrity id %d", errUnsupportedSuite, suite.IntegrityID) + } +} + +func prf(suite negotiatedSuite, key, data []byte) ([]byte, error) { + hashFactory, _, err := suite.prf() + if err != nil { + return nil, err + } + mac := hmac.New(hashFactory, key) + _, _ = mac.Write(data) + return mac.Sum(nil), nil +} + +func prfPlus(suite negotiatedSuite, key, seed []byte, length int) ([]byte, error) { + if length < 0 { + return nil, errors.New("ike: negative key stream length") + } + result := make([]byte, 0, length) + var previous []byte + for counter := byte(1); len(result) < length; counter++ { + if counter == 0 { + return nil, errors.New("ike: PRF+ output is too long") + } + input := make([]byte, 0, len(previous)+len(seed)+1) + input = append(input, previous...) + input = append(input, seed...) + input = append(input, counter) + block, err := prf(suite, key, input) + if err != nil { + return nil, err + } + result = append(result, block...) + previous = block + } + return result[:length], nil +} + +func deriveIKEKeys( + suite negotiatedSuite, + sharedSecret []byte, + initiatorNonce []byte, + responderNonce []byte, + initiatorSPI [8]byte, + responderSPI [8]byte, +) (ikeKeys, error) { + _, preferredLength, err := suite.prf() + if err != nil { + return ikeKeys{}, err + } + encryptionLength, err := suite.encryptionKeyLength() + if err != nil { + return ikeKeys{}, err + } + integrityLength, _, err := suite.integrityLengths() + if err != nil { + return ikeKeys{}, err + } + nonceKey := append(append([]byte(nil), initiatorNonce...), responderNonce...) + skeyseed, err := prf(suite, nonceKey, sharedSecret) + if err != nil { + return ikeKeys{}, err + } + seed := make([]byte, 0, len(initiatorNonce)+len(responderNonce)+16) + seed = append(seed, initiatorNonce...) + seed = append(seed, responderNonce...) + seed = append(seed, initiatorSPI[:]...) + seed = append(seed, responderSPI[:]...) + total := preferredLength + integrityLength*2 + encryptionLength*2 + preferredLength*2 + stream, err := prfPlus(suite, skeyseed, seed, total) + if err != nil { + return ikeKeys{}, err + } + take := func(length int) []byte { + value := append([]byte(nil), stream[:length]...) + stream = stream[length:] + return value + } + return ikeKeys{ + SKd: take(preferredLength), + SKai: take(integrityLength), + SKar: take(integrityLength), + SKei: take(encryptionLength), + SKer: take(encryptionLength), + SKpi: take(preferredLength), + SKpr: take(preferredLength), + }, nil +} + +func integrityMAC(suite negotiatedSuite, key, packetWithoutChecksum []byte) ([]byte, error) { + var hashFactory func() hash.Hash + switch suite.IntegrityID { + case integrityHMACSHA1_96: + hashFactory = sha1.New + case integrityHMACSHA256_128: + hashFactory = sha256.New + default: + return nil, fmt.Errorf("%w: integrity id %d", errUnsupportedSuite, suite.IntegrityID) + } + _, checksumLength, err := suite.integrityLengths() + if err != nil { + return nil, err + } + mac := hmac.New(hashFactory, key) + _, _ = mac.Write(packetWithoutChecksum) + return mac.Sum(nil)[:checksumLength], nil +} + +func encryptPayloads( + header ikeHeader, + inner []payload, + suite negotiatedSuite, + encryptionKey []byte, + integrityKey []byte, + random io.Reader, +) ([]byte, error) { + if random == nil { + random = rand.Reader + } + 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) + } + paddingLength := block.BlockSize() - (len(plaintext)+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) + } + plaintext = append(plaintext, padding...) + plaintext = append(plaintext, 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(plaintext)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, plaintext) + + _, checksumLength, err := suite.integrityLengths() + if err != nil { + return nil, err + } + skLength := 4 + len(iv) + len(ciphertext) + checksumLength + if skLength > 65535 { + return nil, errors.New("ike: encrypted payload exceeds 65535 bytes") + } + body := make([]byte, skLength) + body[0] = first + body[1] = 0 + binary.BigEndian.PutUint16(body[2:4], uint16(skLength)) + copy(body[4:], iv) + copy(body[4+len(iv):], ciphertext) + header.NextPayload = payloadEncrypted + packet := header.marshal(body) + checksum, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength]) + if err != nil { + return nil, err + } + copy(packet[len(packet)-checksumLength:], checksum) + return packet, nil +} + +func decryptPayloads( + packet []byte, + suite negotiatedSuite, + encryptionKey []byte, + integrityKey []byte, +) (ikeHeader, []payload, error) { + header, body, err := parseIKEPacket(packet) + if err != nil { + return ikeHeader{}, nil, err + } + if header.NextPayload != payloadEncrypted || len(body) < 4 { + return ikeHeader{}, nil, fmt.Errorf("%w: message is not an encrypted IKE payload", errUnexpectedPacket) + } + skLength := int(binary.BigEndian.Uint16(body[2:4])) + if skLength != len(body) { + return ikeHeader{}, nil, fmt.Errorf("%w: encrypted payload length mismatch", errMalformedPacket) + } + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return ikeHeader{}, nil, fmt.Errorf("ike: initialize AES: %w", err) + } + _, checksumLength, err := suite.integrityLengths() + if err != nil { + return ikeHeader{}, nil, err + } + if len(body) < 4+block.BlockSize()+block.BlockSize()+checksumLength { + return ikeHeader{}, nil, fmt.Errorf("%w: encrypted payload is too short", errMalformedPacket) + } + expected, err := integrityMAC(suite, integrityKey, packet[:len(packet)-checksumLength]) + if err != nil { + return ikeHeader{}, nil, err + } + actual := packet[len(packet)-checksumLength:] + if subtle.ConstantTimeCompare(actual, expected) != 1 { + return ikeHeader{}, nil, errIntegrityMismatch + } + ivStart := 4 + ciphertextStart := ivStart + block.BlockSize() + ciphertextEnd := len(body) - checksumLength + ciphertext := body[ciphertextStart:ciphertextEnd] + if len(ciphertext) == 0 || len(ciphertext)%block.BlockSize() != 0 { + return ikeHeader{}, nil, fmt.Errorf("%w: 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{}, nil, fmt.Errorf("%w: invalid encrypted payload padding", errMalformedPacket) + } + plaintext = plaintext[:len(plaintext)-paddingLength-1] + payloads, err := parsePayloadChain(body[0], plaintext) + if err != nil { + return ikeHeader{}, nil, err + } + return header, payloads, nil +} + +var modpPrimes = map[uint16]string{ + dhMODP1024: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" + + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" + + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" + + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE65381" + + "FFFFFFFFFFFFFFFF", + dhMODP2048: "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" + + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" + + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" + + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" + + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" + + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" + + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" + + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" + + "15728E5A8AACAA68FFFFFFFFFFFFFFFF", +} + +type dhExchange struct { + Group uint16 + prime *big.Int + private *big.Int + Public []byte +} + +func newDHExchange(group uint16, random io.Reader) (*dhExchange, error) { + primeHex, ok := modpPrimes[group] + if !ok { + return nil, fmt.Errorf("%w: DH group %d", errUnsupportedSuite, group) + } + primeBytes, err := hex.DecodeString(primeHex) + if err != nil { + return nil, fmt.Errorf("ike: internal MODP constant: %w", err) + } + prime := new(big.Int).SetBytes(primeBytes) + if random == nil { + random = rand.Reader + } + sample := make([]byte, len(primeBytes)) + if _, err := io.ReadFull(random, sample); err != nil { + return nil, fmt.Errorf("ike: generate DH private value: %w", err) + } + private := new(big.Int).SetBytes(sample) + private.Mod(private, new(big.Int).Sub(prime, big.NewInt(3))) + private.Add(private, big.NewInt(2)) + publicInteger := new(big.Int).Exp(big.NewInt(2), private, prime) + public := publicInteger.FillBytes(make([]byte, len(primeBytes))) + return &dhExchange{Group: group, prime: prime, private: private, Public: public}, nil +} + +func (exchange *dhExchange) shared(peerPublic []byte) ([]byte, error) { + if exchange == nil || exchange.prime == nil || exchange.private == nil { + return nil, errors.New("ike: DH exchange is not initialized") + } + if len(peerPublic) != len(exchange.Public) { + return nil, fmt.Errorf("ike: peer DH value length %d does not match group length %d", len(peerPublic), len(exchange.Public)) + } + peer := new(big.Int).SetBytes(peerPublic) + upper := new(big.Int).Sub(exchange.prime, big.NewInt(2)) + if peer.Cmp(big.NewInt(2)) < 0 || peer.Cmp(upper) > 0 { + return nil, errors.New("ike: peer DH public value is outside the safe range") + } + shared := new(big.Int).Exp(peer, exchange.private, exchange.prime) + if shared.Sign() == 0 || shared.Cmp(big.NewInt(1)) == 0 { + return nil, errors.New("ike: invalid trivial DH shared secret") + } + return shared.FillBytes(make([]byte, len(exchange.Public))), nil +} + +func natDetectionHash( + initiatorSPI [8]byte, + responderSPI [8]byte, + ip net.IP, + port uint16, +) ([]byte, error) { + if ip4 := ip.To4(); ip4 != nil { + ip = ip4 + } else if ip16 := ip.To16(); ip16 != nil { + ip = ip16 + } else { + return nil, errors.New("ike: NAT detection address is not an IP address") + } + input := make([]byte, 0, 16+len(ip)+2) + input = append(input, initiatorSPI[:]...) + input = append(input, responderSPI[:]...) + input = append(input, ip...) + var encodedPort [2]byte + binary.BigEndian.PutUint16(encodedPort[:], port) + input = append(input, encodedPort[:]...) + sum := sha1.Sum(input) + return sum[:], nil +} diff --git a/internal/vowifi/ike/crypto_test.go b/internal/vowifi/ike/crypto_test.go new file mode 100644 index 0000000..00cb71b --- /dev/null +++ b/internal/vowifi/ike/crypto_test.go @@ -0,0 +1,115 @@ +package ike + +import ( + "bytes" + "errors" + "testing" +) + +func legacyTestSuite() negotiatedSuite { + return negotiatedSuite{ + EncryptionID: encryptionAESCBC, + EncryptionBits: 128, + PRFID: prfHMACSHA1, + IntegrityID: integrityHMACSHA1_96, + DHID: dhMODP1024, + } +} + +func TestMODP1024UsesGroup2PrimeAnd128ByteKE(t *testing.T) { + exchange, err := newDHExchange(dhMODP1024, bytes.NewReader(bytes.Repeat([]byte{0x42}, 128))) + if err != nil { + t.Fatalf("newDHExchange() error = %v", err) + } + if bits := exchange.prime.BitLen(); bits != 1024 { + t.Fatalf("group 2 prime BitLen() = %d, want 1024", bits) + } + if length := len(exchange.Public); length != 128 { + t.Fatalf("group 2 public KE length = %d, want 128", length) + } + peer, err := newDHExchange(dhMODP1024, bytes.NewReader(bytes.Repeat([]byte{0x24}, 128))) + if err != nil { + t.Fatalf("peer newDHExchange() error = %v", err) + } + firstSecret, err := exchange.shared(peer.Public) + if err != nil { + t.Fatalf("exchange.shared() error = %v", err) + } + secondSecret, err := peer.shared(exchange.Public) + if err != nil { + t.Fatalf("peer.shared() error = %v", err) + } + if len(firstSecret) != 128 || !bytes.Equal(firstSecret, secondSecret) { + t.Fatal("MODP group 2 shared secrets differ or are not 128 bytes") + } +} + +func TestEncryptedPayloadRoundTripAndTamperDetection(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: 7, + } + inner := []payload{ + {Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u', '@', 'r'}}, + {Type: payloadEAP, Body: []byte{1, 9, 0, 5, 1}}, + } + packet, err := encryptPayloads( + header, + inner, + suite, + encryptionKey, + integrityKey, + bytes.NewReader(bytes.Repeat([]byte{0x33}, 64)), + ) + if err != nil { + t.Fatalf("encryptPayloads() error = %v", err) + } + decodedHeader, decoded, err := decryptPayloads(packet, suite, encryptionKey, integrityKey) + if err != nil { + t.Fatalf("decryptPayloads() error = %v", err) + } + if decodedHeader.MessageID != header.MessageID || len(decoded) != len(inner) { + t.Fatalf("decoded header/payload count mismatch: %#v %#v", decodedHeader, decoded) + } + 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]) + } + } + + tampered := append([]byte(nil), packet...) + tampered[len(tampered)-1] ^= 0x80 + if _, _, err := decryptPayloads(tampered, suite, encryptionKey, integrityKey); !errors.Is(err, errIntegrityMismatch) { + t.Fatalf("tampered decrypt error = %v, want errIntegrityMismatch", err) + } +} + +func TestIKEKeyDerivationSeparatesDirections(t *testing.T) { + suite := legacyTestSuite() + keys, err := deriveIKEKeys( + suite, + bytes.Repeat([]byte{0x44}, 128), + bytes.Repeat([]byte{0x55}, 32), + bytes.Repeat([]byte{0x66}, 32), + [8]byte{1}, + [8]byte{2}, + ) + if err != nil { + t.Fatalf("deriveIKEKeys() error = %v", err) + } + if len(keys.SKd) != 20 || len(keys.SKai) != 20 || len(keys.SKar) != 20 || + len(keys.SKei) != 16 || len(keys.SKer) != 16 || + len(keys.SKpi) != 20 || len(keys.SKpr) != 20 { + t.Fatalf("unexpected key lengths: %+v", keys) + } + if bytes.Equal(keys.SKai, keys.SKar) || bytes.Equal(keys.SKei, keys.SKer) || + bytes.Equal(keys.SKpi, keys.SKpr) { + t.Fatal("initiator and responder keys were not separated") + } +} diff --git a/internal/vowifi/ike/doc.go b/internal/vowifi/ike/doc.go new file mode 100644 index 0000000..333aceb --- /dev/null +++ b/internal/vowifi/ike/doc.go @@ -0,0 +1,3 @@ +// Package ike implements the VoWiFi SWu control plane directly from the +// IKEv2 and EAP-AKA wire specifications. +package ike diff --git a/internal/vowifi/ike/eap.go b/internal/vowifi/ike/eap.go new file mode 100644 index 0000000..63fa00b --- /dev/null +++ b/internal/vowifi/ike/eap.go @@ -0,0 +1,651 @@ +package ike + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "crypto/subtle" + "encoding/binary" + "errors" + "fmt" + "math/big" + "strings" + + "vocat/internal/vowifi" +) + +const ( + eapRequest = 1 + eapResponse = 2 + eapSuccess = 3 + eapFailure = 4 + + eapTypeIdentity = 1 + eapTypeAKA = 23 + + akaSubtypeChallenge = 1 + akaSubtypeAuthReject = 2 + akaSubtypeSyncFailure = 4 + akaSubtypeIdentity = 5 + akaSubtypeNotification = 12 + akaSubtypeReauth = 13 + akaSubtypeClientError = 14 + + akaAttrRAND = 1 + akaAttrAUTN = 2 + akaAttrRES = 3 + akaAttrAUTS = 4 + akaAttrPermanentIDReq = 10 + akaAttrMAC = 11 + akaAttrNotification = 12 + akaAttrAnyIDReq = 13 + akaAttrIdentity = 14 + akaAttrFullAuthIDReq = 17 + akaAttrClientError = 22 + akaAttrResultInd = 135 +) + +var errAKAProvider = errors.New("ike: SIM AKA provider failure") + +type eapPacket struct { + Code uint8 + Identifier uint8 + Type uint8 + Data []byte +} + +func parseEAPPacket(encoded []byte) (eapPacket, error) { + if len(encoded) < 4 { + return eapPacket{}, errors.New("ike: truncated EAP header") + } + length := int(binary.BigEndian.Uint16(encoded[2:4])) + if length != len(encoded) { + return eapPacket{}, fmt.Errorf("ike: EAP length %d does not match payload length %d", length, len(encoded)) + } + packet := eapPacket{Code: encoded[0], Identifier: encoded[1]} + switch packet.Code { + case eapRequest, eapResponse: + if len(encoded) < 5 { + return eapPacket{}, errors.New("ike: typed EAP packet is truncated") + } + packet.Type = encoded[4] + packet.Data = append([]byte(nil), encoded[5:]...) + case eapSuccess, eapFailure: + if len(encoded) != 4 { + return eapPacket{}, errors.New("ike: EAP success/failure has trailing data") + } + default: + return eapPacket{}, fmt.Errorf("ike: unsupported EAP code %d", packet.Code) + } + return packet, nil +} + +func marshalEAPPacket(packet eapPacket) ([]byte, error) { + length := 4 + if packet.Code == eapRequest || packet.Code == eapResponse { + if packet.Type == 0 { + return nil, errors.New("ike: typed EAP packet has no type") + } + length += 1 + len(packet.Data) + } else if len(packet.Data) != 0 || packet.Type != 0 { + return nil, errors.New("ike: EAP success/failure cannot carry type data") + } + if length > 65535 { + return nil, errors.New("ike: EAP packet exceeds 65535 bytes") + } + encoded := make([]byte, length) + encoded[0] = packet.Code + encoded[1] = packet.Identifier + binary.BigEndian.PutUint16(encoded[2:4], uint16(length)) + if length > 4 { + encoded[4] = packet.Type + copy(encoded[5:], packet.Data) + } + return encoded, nil +} + +type akaAttribute struct { + Type uint8 + Raw []byte + Offset int +} + +func parseAKAAttributes(encoded []byte) ([]akaAttribute, error) { + var result []akaAttribute + for offset := 0; offset < len(encoded); { + if len(result) >= 64 || offset+2 > len(encoded) { + return nil, errors.New("ike: malformed EAP-AKA attribute list") + } + length := int(encoded[offset+1]) * 4 + if length < 4 || offset+length > len(encoded) { + return nil, fmt.Errorf("ike: EAP-AKA attribute %d has invalid length %d", encoded[offset], length) + } + result = append(result, akaAttribute{ + Type: encoded[offset], + Raw: append([]byte(nil), encoded[offset:offset+length]...), + Offset: offset, + }) + offset += length + } + return result, nil +} + +func oneAKAAttribute(attributes []akaAttribute, kind uint8) (akaAttribute, error) { + var result akaAttribute + count := 0 + for _, attribute := range attributes { + if attribute.Type == kind { + result = attribute + count++ + } + } + if count != 1 { + return akaAttribute{}, fmt.Errorf("ike: EAP-AKA expected one attribute %d, got %d", kind, count) + } + return result, nil +} + +func marshalAKAAttribute(kind uint8, value []byte) ([]byte, error) { + length := 2 + len(value) + padded := (length + 3) &^ 3 + if padded/4 > 255 { + return nil, errors.New("ike: EAP-AKA attribute is too long") + } + encoded := make([]byte, padded) + encoded[0] = kind + encoded[1] = uint8(padded / 4) + copy(encoded[2:], value) + return encoded, nil +} + +type akaKeys struct { + KEncr []byte + KAut []byte + MSK []byte + EMSK []byte +} + +func deriveAKAKeys(identity, ik, ck []byte) (akaKeys, error) { + if len(identity) == 0 { + return akaKeys{}, errors.New("ike: EAP-AKA identity is empty") + } + if len(ik) != 16 || len(ck) != 16 { + return akaKeys{}, fmt.Errorf("ike: EAP-AKA requires 16-byte IK and CK, got %d and %d", len(ik), len(ck)) + } + material := make([]byte, 0, len(identity)+32) + material = append(material, identity...) + material = append(material, ik...) + material = append(material, ck...) + masterKey := sha1.Sum(material) + stream := fips1862PRF(masterKey[:], 160) + return akaKeys{ + KEncr: append([]byte(nil), stream[0:16]...), + KAut: append([]byte(nil), stream[16:32]...), + MSK: append([]byte(nil), stream[32:96]...), + EMSK: append([]byte(nil), stream[96:160]...), + }, nil +} + +func fips1862PRF(seed []byte, length int) []byte { + xkey := new(big.Int).SetBytes(seed) + modulus := new(big.Int).Lsh(big.NewInt(1), 160) + result := make([]byte, 0, length) + for len(result) < length { + xval := xkey.FillBytes(make([]byte, 20)) + word := fipsSHA1G(xval) + result = append(result, word[:]...) + increment := new(big.Int).SetBytes(word[:]) + xkey.Add(xkey, increment) + xkey.Add(xkey, big.NewInt(1)) + xkey.Mod(xkey, modulus) + } + return result[:length] +} + +// fipsSHA1G is the SHA-1 compression function G(t, XVAL) from FIPS 186-2. +// Unlike ordinary SHA-1, the 160-bit XVAL is zero-filled to one compression +// block and is not followed by SHA-1 message padding. +func fipsSHA1G(xval []byte) [20]byte { + var words [80]uint32 + var block [64]byte + copy(block[:20], xval) + for index := 0; index < 16; index++ { + words[index] = binary.BigEndian.Uint32(block[index*4 : index*4+4]) + } + for index := 16; index < 80; index++ { + value := words[index-3] ^ words[index-8] ^ words[index-14] ^ words[index-16] + words[index] = value<<1 | value>>31 + } + a := uint32(0x67452301) + b := uint32(0xEFCDAB89) + c := uint32(0x98BADCFE) + d := uint32(0x10325476) + e := uint32(0xC3D2E1F0) + initialA, initialB, initialC, initialD, initialE := a, b, c, d, e + for index := 0; index < 80; index++ { + var function, constant uint32 + switch { + case index < 20: + function = (b & c) | (^b & d) + constant = 0x5A827999 + case index < 40: + function = b ^ c ^ d + constant = 0x6ED9EBA1 + case index < 60: + function = (b & c) | (b & d) | (c & d) + constant = 0x8F1BBCDC + default: + function = b ^ c ^ d + constant = 0xCA62C1D6 + } + rotatedA := a<<5 | a>>27 + next := rotatedA + function + e + constant + words[index] + e = d + d = c + c = b<<30 | b>>2 + b = a + a = next + } + values := [5]uint32{initialA + a, initialB + b, initialC + c, initialD + d, initialE + e} + var result [20]byte + for index, value := range values { + binary.BigEndian.PutUint32(result[index*4:index*4+4], value) + } + return result +} + +func permanentAKAIdentity(identity vowifi.SIMIdentity) ([]byte, error) { + imsi := strings.TrimSpace(identity.IMSI) + if len(imsi) < 5 || len(imsi) > 16 { + return nil, errors.New("ike: IMSI length is invalid for EAP-AKA") + } + for _, digit := range imsi { + if digit < '0' || digit > '9' { + return nil, errors.New("ike: IMSI contains a non-digit") + } + } + mcc := strings.TrimSpace(identity.HomeMCC) + mnc := strings.TrimSpace(identity.HomeMNC) + if len(mcc) != 3 || (len(mnc) != 2 && len(mnc) != 3) { + return nil, errors.New("ike: explicit home MCC/MNC is required for EAP-AKA") + } + for len(mnc) < 3 { + mnc = "0" + mnc + } + return []byte(fmt.Sprintf("0%s@nai.epc.mnc%s.mcc%s.3gppnetwork.org", imsi, mnc, mcc)), nil +} + +type eapAction struct { + Response []byte + Success bool +} + +type akaClient struct { + identity []byte + simIdentity vowifi.SIMIdentity + provider vowifi.AKAProvider + keys akaKeys + challengeComplete bool + resultIndication bool + protectedSuccess bool +} + +func newAKAClient(identity vowifi.SIMIdentity, provider vowifi.AKAProvider) (*akaClient, error) { + if provider == nil { + return nil, errors.New("ike: AKA provider is required") + } + nai, err := permanentAKAIdentity(identity) + if err != nil { + return nil, err + } + return &akaClient{identity: nai, simIdentity: identity, provider: provider}, nil +} + +func (client *akaClient) handle(ctx context.Context, encoded []byte) (eapAction, error) { + packet, err := parseEAPPacket(encoded) + if err != nil { + return eapAction{}, err + } + switch packet.Code { + case eapFailure: + stage := "before the SIM AKA challenge (identity or subscription rejected)" + if client.challengeComplete { + stage = "after the SIM AKA response (AKA result or subscription rejected)" + } + return eapAction{}, fmt.Errorf("%w %s", vowifi.ErrEAPAuthenticationRejected, stage) + case eapSuccess: + if !client.challengeComplete { + return eapAction{}, errors.New("ike: EAP success arrived before an authenticated AKA challenge") + } + if client.resultIndication && !client.protectedSuccess { + return eapAction{}, errors.New("ike: unprotected EAP success received after AT_RESULT_IND") + } + return eapAction{Success: true}, nil + case eapRequest: + default: + return eapAction{}, fmt.Errorf("ike: unexpected EAP code %d from responder", packet.Code) + } + switch packet.Type { + case eapTypeIdentity: + response, err := marshalEAPPacket(eapPacket{ + Code: eapResponse, + Identifier: packet.Identifier, + Type: eapTypeIdentity, + Data: client.identity, + }) + return eapAction{Response: response}, err + case eapTypeAKA: + return client.handleAKARequest(ctx, packet) + default: + return eapAction{}, fmt.Errorf("ike: responder requested unsupported EAP type %d", packet.Type) + } +} + +func (client *akaClient) handleAKARequest(ctx context.Context, packet eapPacket) (eapAction, error) { + if len(packet.Data) < 3 { + return akaClientErrorResponse(packet.Identifier) + } + subtype := packet.Data[0] + if packet.Data[1] != 0 || packet.Data[2] != 0 { + return akaClientErrorResponse(packet.Identifier) + } + attributes, err := parseAKAAttributes(packet.Data[3:]) + if err != nil { + return akaClientErrorResponse(packet.Identifier) + } + switch subtype { + case akaSubtypeIdentity: + action, err := client.respondAKAIdentity(packet.Identifier, attributes) + if err != nil { + return akaClientErrorResponse(packet.Identifier) + } + return action, nil + case akaSubtypeChallenge: + action, err := client.respondAKAChallenge(ctx, packet.Identifier, attributes, packet) + if err != nil && !errors.Is(err, errAKAProvider) && + !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + return akaClientErrorResponse(packet.Identifier) + } + return action, err + case akaSubtypeNotification: + return client.respondAKANotification(packet.Identifier, attributes, packet) + case akaSubtypeReauth: + return akaClientErrorResponse(packet.Identifier) + default: + return akaClientErrorResponse(packet.Identifier) + } +} + +func (client *akaClient) respondAKAIdentity(identifier uint8, attributes []akaAttribute) (eapAction, error) { + requests := 0 + for _, attribute := range attributes { + switch attribute.Type { + case akaAttrPermanentIDReq, akaAttrAnyIDReq, akaAttrFullAuthIDReq: + if len(attribute.Raw) != 4 { + return eapAction{}, errors.New("ike: malformed EAP-AKA identity request attribute") + } + requests++ + default: + if attribute.Type < 128 { + return eapAction{}, fmt.Errorf("ike: unsupported mandatory EAP-AKA identity attribute %d", attribute.Type) + } + } + } + if requests != 1 { + return eapAction{}, errors.New("ike: EAP-AKA identity request must contain exactly one request attribute") + } + identityAttribute, err := marshalAKAAttribute(akaAttrIdentity, append([]byte{byte(len(client.identity) >> 8), byte(len(client.identity))}, client.identity...)) + if err != nil { + return eapAction{}, err + } + data := append([]byte{akaSubtypeIdentity, 0, 0}, identityAttribute...) + response, err := marshalEAPPacket(eapPacket{ + Code: eapResponse, + Identifier: identifier, + Type: eapTypeAKA, + Data: data, + }) + return eapAction{Response: response}, err +} + +func (client *akaClient) respondAKAChallenge( + ctx context.Context, + identifier uint8, + attributes []akaAttribute, + request eapPacket, +) (eapAction, error) { + for _, attribute := range attributes { + switch attribute.Type { + case akaAttrRAND, akaAttrAUTN, akaAttrMAC, akaAttrResultInd: + default: + if attribute.Type < 128 { + return eapAction{}, fmt.Errorf("ike: unknown mandatory EAP-AKA challenge attribute %d", attribute.Type) + } + } + } + randAttribute, err := oneAKAAttribute(attributes, akaAttrRAND) + if err != nil { + return eapAction{}, err + } + autnAttribute, err := oneAKAAttribute(attributes, akaAttrAUTN) + if err != nil { + return eapAction{}, err + } + macAttribute, err := oneAKAAttribute(attributes, akaAttrMAC) + if err != nil { + return eapAction{}, err + } + if len(randAttribute.Raw) != 20 || len(autnAttribute.Raw) != 20 || len(macAttribute.Raw) != 20 { + return eapAction{}, errors.New("ike: EAP-AKA RAND, AUTN, or MAC has an invalid length") + } + var challenge vowifi.AKAChallenge + copy(challenge.RAND[:], randAttribute.Raw[4:20]) + copy(challenge.AUTN[:], autnAttribute.Raw[4:20]) + result, err := client.provider.Authenticate(ctx, client.simIdentity, challenge) + if err != nil { + if errors.Is(err, vowifi.ErrEC20AKAMACFailure) { + return akaAuthenticationRejectResponse(identifier) + } + return eapAction{}, errors.Join(errAKAProvider, fmt.Errorf("ike: SIM AKA authentication: %w", err)) + } + if result.SynchronizationFailure { + if len(result.AUTS) != 14 { + return eapAction{}, errors.New("ike: SIM reported synchronization failure without a 14-byte AUTS") + } + autsAttribute, err := marshalAKAAttribute(akaAttrAUTS, result.AUTS) + if err != nil { + return eapAction{}, err + } + data := append([]byte{akaSubtypeSyncFailure, 0, 0}, autsAttribute...) + response, err := marshalEAPPacket(eapPacket{Code: eapResponse, Identifier: identifier, Type: eapTypeAKA, Data: data}) + return eapAction{Response: response}, err + } + if len(result.RES) < 4 || len(result.RES) > 16 { + return eapAction{}, fmt.Errorf("ike: SIM returned invalid RES length %d", len(result.RES)) + } + keys, err := deriveAKAKeys(client.identity, result.IK, result.CK) + if err != nil { + return eapAction{}, err + } + requestBytes, err := marshalEAPPacket(request) + if err != nil { + return eapAction{}, err + } + zeroed := append([]byte(nil), requestBytes...) + macOffset := 5 + 3 + macAttribute.Offset + if macOffset+20 > len(zeroed) { + return eapAction{}, errors.New("ike: EAP-AKA MAC offset is invalid") + } + for index := macOffset + 4; index < macOffset+20; index++ { + zeroed[index] = 0 + } + expectedMAC := akaMAC(keys.KAut, zeroed) + if subtle.ConstantTimeCompare(expectedMAC, macAttribute.Raw[4:20]) != 1 { + return eapAction{}, errors.New("ike: EAP-AKA server MAC is invalid") + } + + resValue := make([]byte, 2+len(result.RES)) + binary.BigEndian.PutUint16(resValue[0:2], uint16(len(result.RES)*8)) + copy(resValue[2:], result.RES) + resAttribute, err := marshalAKAAttribute(akaAttrRES, resValue) + if err != nil { + return eapAction{}, err + } + macResponse, _ := marshalAKAAttribute(akaAttrMAC, make([]byte, 18)) + responseData := append([]byte{akaSubtypeChallenge, 0, 0}, resAttribute...) + resultIndication := false + for _, attribute := range attributes { + if attribute.Type == akaAttrResultInd { + if len(attribute.Raw) != 4 { + return eapAction{}, errors.New("ike: malformed AT_RESULT_IND") + } + responseData = append(responseData, attribute.Raw...) + resultIndication = true + } + } + responseData = append(responseData, macResponse...) + responseBytes, err := marshalEAPPacket(eapPacket{ + Code: eapResponse, + Identifier: identifier, + Type: eapTypeAKA, + Data: responseData, + }) + if err != nil { + return eapAction{}, err + } + responseAttributes, _ := parseAKAAttributes(responseData[3:]) + responseMAC, err := oneAKAAttribute(responseAttributes, akaAttrMAC) + if err != nil { + return eapAction{}, err + } + responseMACOffset := 5 + 3 + responseMAC.Offset + computed := akaMAC(keys.KAut, responseBytes) + copy(responseBytes[responseMACOffset+4:responseMACOffset+20], computed) + client.keys = keys + client.challengeComplete = true + client.resultIndication = resultIndication + return eapAction{Response: responseBytes}, nil +} + +func (client *akaClient) respondAKANotification( + identifier uint8, + attributes []akaAttribute, + request eapPacket, +) (eapAction, error) { + notification, err := oneAKAAttribute(attributes, akaAttrNotification) + if err != nil { + return eapAction{}, err + } + if len(notification.Raw) != 4 { + return eapAction{}, errors.New("ike: malformed EAP-AKA notification") + } + code := binary.BigEndian.Uint16(notification.Raw[2:4]) + if code != 32768 { + responseData := []byte{akaSubtypeNotification, 0, 0} + if code&0x4000 == 0 { + if !client.challengeComplete { + return akaClientErrorResponse(identifier) + } + macAttribute, err := oneAKAAttribute(attributes, akaAttrMAC) + if err != nil || len(macAttribute.Raw) != 20 { + return akaClientErrorResponse(identifier) + } + requestBytes, err := marshalEAPPacket(request) + if err != nil { + return eapAction{}, err + } + zeroed := append([]byte(nil), requestBytes...) + macOffset := 5 + 3 + macAttribute.Offset + for index := macOffset + 4; index < macOffset+20; index++ { + zeroed[index] = 0 + } + if subtle.ConstantTimeCompare(akaMAC(client.keys.KAut, zeroed), macAttribute.Raw[4:20]) != 1 { + return akaClientErrorResponse(identifier) + } + responseMAC, _ := marshalAKAAttribute(akaAttrMAC, make([]byte, 18)) + responseData = append(responseData, responseMAC...) + } + responseBytes, err := marshalEAPPacket(eapPacket{ + Code: eapResponse, Identifier: identifier, Type: eapTypeAKA, Data: responseData, + }) + if err != nil { + return eapAction{}, err + } + if code&0x4000 == 0 { + responseAttributes, _ := parseAKAAttributes(responseData[3:]) + responseMAC, _ := oneAKAAttribute(responseAttributes, akaAttrMAC) + offset := 5 + 3 + responseMAC.Offset + copy(responseBytes[offset+4:offset+20], akaMAC(client.keys.KAut, responseBytes)) + } + return eapAction{Response: responseBytes}, nil + } + if !client.challengeComplete || !client.resultIndication { + return eapAction{}, errors.New("ike: unexpected protected EAP-AKA success notification") + } + macAttribute, err := oneAKAAttribute(attributes, akaAttrMAC) + if err != nil { + return eapAction{}, err + } + if len(macAttribute.Raw) != 20 { + return eapAction{}, errors.New("ike: malformed notification AT_MAC") + } + requestBytes, err := marshalEAPPacket(request) + if err != nil { + return eapAction{}, err + } + zeroed := append([]byte(nil), requestBytes...) + macOffset := 5 + 3 + macAttribute.Offset + for index := macOffset + 4; index < macOffset+20; index++ { + zeroed[index] = 0 + } + if subtle.ConstantTimeCompare(akaMAC(client.keys.KAut, zeroed), macAttribute.Raw[4:20]) != 1 { + return eapAction{}, errors.New("ike: EAP-AKA protected success MAC is invalid") + } + responseMAC, _ := marshalAKAAttribute(akaAttrMAC, make([]byte, 18)) + responseData := append([]byte{akaSubtypeNotification, 0, 0}, responseMAC...) + responseBytes, err := marshalEAPPacket(eapPacket{ + Code: eapResponse, + Identifier: identifier, + Type: eapTypeAKA, + Data: responseData, + }) + if err != nil { + return eapAction{}, err + } + responseAttributes, _ := parseAKAAttributes(responseData[3:]) + responseMACAttribute, _ := oneAKAAttribute(responseAttributes, akaAttrMAC) + responseOffset := 5 + 3 + responseMACAttribute.Offset + copy(responseBytes[responseOffset+4:responseOffset+20], akaMAC(client.keys.KAut, responseBytes)) + client.protectedSuccess = true + return eapAction{Response: responseBytes}, nil +} + +func akaMAC(key, packet []byte) []byte { + mac := hmac.New(sha1.New, key) + _, _ = mac.Write(packet) + return mac.Sum(nil)[:16] +} + +func akaClientErrorResponse(identifier uint8) (eapAction, error) { + attribute, err := marshalAKAAttribute(akaAttrClientError, []byte{0, 0}) + if err != nil { + return eapAction{}, err + } + response, err := marshalEAPPacket(eapPacket{ + Code: eapResponse, + Identifier: identifier, + Type: eapTypeAKA, + Data: append([]byte{akaSubtypeClientError, 0, 0}, attribute...), + }) + return eapAction{Response: response}, err +} + +func akaAuthenticationRejectResponse(identifier uint8) (eapAction, error) { + response, err := marshalEAPPacket(eapPacket{ + Code: eapResponse, + Identifier: identifier, + Type: eapTypeAKA, + Data: []byte{akaSubtypeAuthReject, 0, 0}, + }) + return eapAction{Response: response}, err +} diff --git a/internal/vowifi/ike/eap_test.go b/internal/vowifi/ike/eap_test.go new file mode 100644 index 0000000..781f7fd --- /dev/null +++ b/internal/vowifi/ike/eap_test.go @@ -0,0 +1,224 @@ +package ike + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "vocat/internal/vowifi" +) + +type testAKAProvider struct { + result vowifi.AKAResult + err error + challenge vowifi.AKAChallenge + calls int +} + +func (provider *testAKAProvider) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) { + return vowifi.AKAEvidence{Ready: true, Application: "USIM"}, nil +} + +func (provider *testAKAProvider) Authenticate( + _ context.Context, + _ vowifi.SIMIdentity, + challenge vowifi.AKAChallenge, +) (vowifi.AKAResult, error) { + provider.calls++ + provider.challenge = challenge + return provider.result, provider.err +} + +func testSIMIdentity() vowifi.SIMIdentity { + return vowifi.SIMIdentity{ + IMSI: "234150123456789", + HomeMCC: "234", + HomeMNC: "15", + } +} + +func TestEAPAKAIdentityRequiresExactlyOneRequestAttribute(t *testing.T) { + client, err := newAKAClient(testSIMIdentity(), &testAKAProvider{}) + if err != nil { + t.Fatalf("newAKAClient() error = %v", err) + } + request, err := marshalEAPPacket(eapPacket{ + Code: eapRequest, + Identifier: 9, + Type: eapTypeAKA, + Data: []byte{akaSubtypeIdentity, 0, 0}, + }) + if err != nil { + t.Fatal(err) + } + action, err := client.handle(context.Background(), request) + if err != nil { + t.Fatalf("handle malformed identity error = %v", err) + } + response, err := parseEAPPacket(action.Response) + if err != nil { + t.Fatal(err) + } + if response.Code != eapResponse || response.Identifier != 9 || + response.Type != eapTypeAKA || len(response.Data) < 1 || + response.Data[0] != akaSubtypeClientError { + t.Fatalf("malformed identity response = %#v", response) + } + + permanent, _ := marshalAKAAttribute(akaAttrPermanentIDReq, []byte{0, 0}) + validRequest, _ := marshalEAPPacket(eapPacket{ + Code: eapRequest, + Identifier: 10, + Type: eapTypeAKA, + Data: append([]byte{akaSubtypeIdentity, 0, 0}, permanent...), + }) + action, err = client.handle(context.Background(), validRequest) + if err != nil { + t.Fatalf("handle valid identity error = %v", err) + } + response, _ = parseEAPPacket(action.Response) + if response.Data[0] != akaSubtypeIdentity { + t.Fatalf("valid identity response subtype = %d", response.Data[0]) + } + attributes, err := parseAKAAttributes(response.Data[3:]) + if err != nil { + t.Fatal(err) + } + identity, err := oneAKAAttribute(attributes, akaAttrIdentity) + if err != nil { + t.Fatal(err) + } + length := int(identity.Raw[2])<<8 | int(identity.Raw[3]) + if got := string(identity.Raw[4 : 4+length]); got != "0234150123456789@nai.epc.mnc015.mcc234.3gppnetwork.org" { + t.Fatalf("permanent AKA identity = %q", got) + } +} + +func TestEAPFailureReportsAuthenticationStage(t *testing.T) { + client, err := newAKAClient(testSIMIdentity(), &testAKAProvider{}) + if err != nil { + t.Fatal(err) + } + failure, err := marshalEAPPacket(eapPacket{Code: eapFailure, Identifier: 3}) + if err != nil { + t.Fatal(err) + } + _, err = client.handle(context.Background(), failure) + if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "before the SIM AKA challenge") { + t.Fatalf("pre-challenge failure = %v", err) + } + client.challengeComplete = true + _, err = client.handle(context.Background(), failure) + if !errors.Is(err, vowifi.ErrEAPAuthenticationRejected) || !strings.Contains(err.Error(), "after the SIM AKA response") { + t.Fatalf("post-challenge failure = %v", err) + } +} + +func TestEAPAKAChallengeTypedSIMAndMAC(t *testing.T) { + result := vowifi.AKAResult{ + RES: bytes.Repeat([]byte{0x91}, 8), + CK: bytes.Repeat([]byte{0x92}, 16), + IK: bytes.Repeat([]byte{0x93}, 16), + } + provider := &testAKAProvider{result: result} + client, err := newAKAClient(testSIMIdentity(), provider) + if err != nil { + t.Fatal(err) + } + keys, err := deriveAKAKeys(client.identity, result.IK, result.CK) + if err != nil { + t.Fatal(err) + } + randValue := bytes.Repeat([]byte{0xa1}, 16) + autnValue := bytes.Repeat([]byte{0xa2}, 16) + randAttribute, _ := marshalAKAAttribute(akaAttrRAND, append([]byte{0, 0}, randValue...)) + autnAttribute, _ := marshalAKAAttribute(akaAttrAUTN, append([]byte{0, 0}, autnValue...)) + macAttribute, _ := marshalAKAAttribute(akaAttrMAC, make([]byte, 18)) + data := []byte{akaSubtypeChallenge, 0, 0} + data = append(data, randAttribute...) + data = append(data, autnAttribute...) + data = append(data, macAttribute...) + request, _ := marshalEAPPacket(eapPacket{ + Code: eapRequest, + Identifier: 21, + Type: eapTypeAKA, + Data: data, + }) + attributes, _ := parseAKAAttributes(data[3:]) + mac, _ := oneAKAAttribute(attributes, akaAttrMAC) + macOffset := 5 + 3 + mac.Offset + copy(request[macOffset+4:macOffset+20], akaMAC(keys.KAut, request)) + + action, err := client.handle(context.Background(), request) + if err != nil { + t.Fatalf("handle challenge error = %v", err) + } + if provider.calls != 1 || !bytes.Equal(provider.challenge.RAND[:], randValue) || + !bytes.Equal(provider.challenge.AUTN[:], autnValue) { + t.Fatalf("typed SIM challenge = %#v calls=%d", provider.challenge, provider.calls) + } + response, err := parseEAPPacket(action.Response) + if err != nil { + t.Fatal(err) + } + if response.Data[0] != akaSubtypeChallenge { + t.Fatalf("challenge response subtype = %d", response.Data[0]) + } + responseAttributes, err := parseAKAAttributes(response.Data[3:]) + if err != nil { + t.Fatal(err) + } + res, err := oneAKAAttribute(responseAttributes, akaAttrRES) + if err != nil { + t.Fatal(err) + } + if bits := int(res.Raw[2])<<8 | int(res.Raw[3]); bits != len(result.RES)*8 { + t.Fatalf("AT_RES bits = %d", bits) + } + responseMAC, err := oneAKAAttribute(responseAttributes, akaAttrMAC) + if err != nil { + t.Fatal(err) + } + zeroed := append([]byte(nil), action.Response...) + responseOffset := 5 + 3 + responseMAC.Offset + actualMAC := append([]byte(nil), zeroed[responseOffset+4:responseOffset+20]...) + for index := responseOffset + 4; index < responseOffset+20; index++ { + zeroed[index] = 0 + } + if !bytes.Equal(actualMAC, akaMAC(keys.KAut, zeroed)) { + t.Fatal("response AT_MAC does not authenticate the complete EAP packet") + } + success, _ := marshalEAPPacket(eapPacket{Code: eapSuccess, Identifier: 22}) + finalAction, err := client.handle(context.Background(), success) + if err != nil || !finalAction.Success { + t.Fatalf("authenticated EAP success = %#v err=%v", finalAction, err) + } +} + +func TestEAPAKAUSIMNetworkAuthenticationFailureSendsReject(t *testing.T) { + provider := &testAKAProvider{err: vowifi.ErrEC20AKAMACFailure} + client, err := newAKAClient(testSIMIdentity(), provider) + if err != nil { + t.Fatal(err) + } + randAttribute, _ := marshalAKAAttribute(akaAttrRAND, append([]byte{0, 0}, bytes.Repeat([]byte{1}, 16)...)) + autnAttribute, _ := marshalAKAAttribute(akaAttrAUTN, append([]byte{0, 0}, bytes.Repeat([]byte{2}, 16)...)) + macAttribute, _ := marshalAKAAttribute(akaAttrMAC, make([]byte, 18)) + data := append([]byte{akaSubtypeChallenge, 0, 0}, randAttribute...) + data = append(data, autnAttribute...) + data = append(data, macAttribute...) + request, _ := marshalEAPPacket(eapPacket{Code: eapRequest, Identifier: 5, Type: eapTypeAKA, Data: data}) + action, err := client.handle(context.Background(), request) + if err != nil { + t.Fatalf("handle USIM MAC failure error = %v", err) + } + response, _ := parseEAPPacket(action.Response) + if len(response.Data) != 3 || response.Data[0] != akaSubtypeAuthReject { + t.Fatalf("USIM MAC failure response = %#v", response) + } + if !errors.Is(provider.err, vowifi.ErrEC20AKAMACFailure) { + t.Fatal("test provider lost the MAC failure sentinel") + } +} diff --git a/internal/vowifi/ike/epdg_resolver.go b/internal/vowifi/ike/epdg_resolver.go new file mode 100644 index 0000000..e95fb77 --- /dev/null +++ b/internal/vowifi/ike/epdg_resolver.go @@ -0,0 +1,144 @@ +package ike + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "strings" + "time" +) + +const googleDNSOverHTTPS = "https://dns.google/resolve" + +// A small number of operators publish the standard ePDG CNAME globally but +// return its A records only when the recursive DNS query appears to originate +// in the home country. Keep this list deliberately narrow: ordinary ePDGs must +// continue to use the host resolver, and a fallback is attempted only after +// that resolver has failed. +var geoRestrictedEPDGSubnets = map[string]string{ + "epdg.epc.mnc002.mcc262.pub.3gppnetwork.org": "109.192.0.0/24", // Vodafone Germany +} + +type dnsOverHTTPSResponse struct { + Status int `json:"Status"` + Answer []struct { + Type int `json:"type"` + Data string `json:"data"` + } `json:"Answer"` +} + +func resolveEPDG(ctx context.Context, resolver *net.Resolver, host string) ([]net.IPAddr, error) { + if resolver == nil { + resolver = net.DefaultResolver + } + addresses, systemErr := resolver.LookupIPAddr(ctx, host) + if systemErr == nil && len(addresses) > 0 { + return addresses, nil + } + + normalized := strings.ToLower(strings.TrimSuffix(strings.TrimSpace(host), ".")) + subnet := geoRestrictedEPDGSubnets[normalized] + if subnet == "" { + if systemErr != nil { + return nil, systemErr + } + return nil, errors.New("ePDG did not resolve to an IP address") + } + + client := &http.Client{Timeout: 8 * time.Second} + var fallbackErr error + // Vodafone's authoritative response has a 60-second TTL and recursive + // resolvers can briefly cache the global CNAME without its geo-restricted + // address records. Stay inside the runtime's two-minute setup window and + // wait through one complete negative-cache TTL so a single reconnect is + // sufficient; users should not have to click Reconnect repeatedly. + const fallbackAttempts = 13 + for attempt := 0; attempt < fallbackAttempts; attempt++ { + var fallback []net.IPAddr + fallback, fallbackErr = resolveEPDGWithECS(ctx, client, googleDNSOverHTTPS, normalized, subnet) + if fallbackErr == nil && len(fallback) > 0 { + return fallback, nil + } + if attempt+1 < fallbackAttempts { + select { + case <-time.After(5 * time.Second): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + } + if systemErr == nil { + systemErr = errors.New("system DNS returned no IP addresses") + } + return nil, fmt.Errorf("system DNS failed (%v); geographic DNS fallback failed: %w", systemErr, fallbackErr) +} + +func resolveEPDGWithECS( + ctx context.Context, + client *http.Client, + endpoint, host, subnet string, +) ([]net.IPAddr, error) { + if client == nil { + return nil, errors.New("nil DNS-over-HTTPS client") + } + parsed, err := url.Parse(endpoint) + if err != nil { + return nil, fmt.Errorf("parse DNS-over-HTTPS endpoint: %w", err) + } + query := parsed.Query() + query.Set("name", strings.TrimSpace(host)) + query.Set("type", "A") + query.Set("edns_client_subnet", strings.TrimSpace(subnet)) + parsed.RawQuery = query.Encode() + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsed.String(), nil) + if err != nil { + return nil, fmt.Errorf("build DNS-over-HTTPS request: %w", err) + } + request.Header.Set("Accept", "application/dns-json") + request.Header.Set("Cache-Control", "no-cache") + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("query DNS-over-HTTPS: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("DNS-over-HTTPS returned HTTP %d", response.StatusCode) + } + var payload dnsOverHTTPSResponse + decoder := json.NewDecoder(response.Body) + if err := decoder.Decode(&payload); err != nil { + return nil, fmt.Errorf("decode DNS-over-HTTPS response: %w", err) + } + if payload.Status != 0 { + return nil, fmt.Errorf("DNS-over-HTTPS returned DNS status %d", payload.Status) + } + result := make([]net.IPAddr, 0, len(payload.Answer)) + for _, answer := range payload.Answer { + if answer.Type != 1 && answer.Type != 28 { + continue + } + ip := net.ParseIP(strings.TrimSuffix(strings.TrimSpace(answer.Data), ".")) + if ip == nil { + continue + } + duplicate := false + for _, existing := range result { + if existing.IP.Equal(ip) { + duplicate = true + break + } + } + if !duplicate { + result = append(result, net.IPAddr{IP: append(net.IP(nil), ip...)}) + } + } + if len(result) == 0 { + return nil, errors.New("DNS-over-HTTPS response contained no ePDG IP addresses") + } + return result, nil +} diff --git a/internal/vowifi/ike/epdg_resolver_test.go b/internal/vowifi/ike/epdg_resolver_test.go new file mode 100644 index 0000000..9757219 --- /dev/null +++ b/internal/vowifi/ike/epdg_resolver_test.go @@ -0,0 +1,56 @@ +package ike + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" +) + +func TestResolveEPDGWithECS(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("name"); got != "epdg.epc.mnc002.mcc262.pub.3gppnetwork.org" { + t.Errorf("name = %q", got) + } + if got := r.URL.Query().Get("edns_client_subnet"); got != "109.192.0.0/24" { + t.Errorf("edns_client_subnet = %q", got) + } + w.Header().Set("Content-Type", "application/dns-json") + _, _ = fmt.Fprint(w, `{ + "Status": 0, + "Question": [{"name":"epdg.example.","type":1}], + "Answer": [ + {"name":"epdg.example.","type":5,"TTL":60,"data":"gateway.example."}, + {"name":"gateway.example.","type":1,"TTL":60,"data":"139.7.117.168"}, + {"name":"gateway.example.","type":1,"TTL":60,"data":"139.7.117.169"}, + {"name":"gateway.example.","type":1,"TTL":60,"data":"139.7.117.168"} + ] + }`) + })) + defer server.Close() + + addresses, err := resolveEPDGWithECS( + context.Background(), + server.Client(), + server.URL, + "epdg.epc.mnc002.mcc262.pub.3gppnetwork.org", + "109.192.0.0/24", + ) + if err != nil { + t.Fatalf("resolveEPDGWithECS: %v", err) + } + if len(addresses) != 2 || addresses[0].IP.String() != "139.7.117.168" || addresses[1].IP.String() != "139.7.117.169" { + t.Fatalf("addresses = %#v", addresses) + } +} + +func TestResolveEPDGWithECSRejectsEmptyAnswer(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = fmt.Fprint(w, `{"Status":0,"Answer":[{"type":5,"data":"gateway.example."}]}`) + })) + defer server.Close() + if _, err := resolveEPDGWithECS(context.Background(), server.Client(), server.URL, "epdg.example", "192.0.2.0/24"); err == nil { + t.Fatal("empty address answer was accepted") + } +} diff --git a/internal/vowifi/ike/esp.go b/internal/vowifi/ike/esp.go new file mode 100644 index 0000000..59b3e97 --- /dev/null +++ b/internal/vowifi/ike/esp.go @@ -0,0 +1,522 @@ +package ike + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/sha256" + "crypto/subtle" + "encoding/binary" + "errors" + "fmt" + "io" + "math" + "net" + "sync" +) + +const ( + espHeaderLength = 8 + espReplayWindow = 64 +) + +var ( + errESPAuthentication = errors.New("ike: ESP authentication failed") + errESPReplay = errors.New("ike: ESP packet is outside the replay window") + errESPPolicyDrop = errors.New("ike: ESP packet is not eligible for this CHILD_SA") +) + +// espTunnel protects complete IPv4 or IPv6 packets using an IKEv2 CHILD_SA. +// It deliberately implements only the negotiated suites offered by this +// package: AES-CBC with HMAC-SHA1-96 or HMAC-SHA2-256-128 and no ESN. +type espTunnel struct { + outbound *espDirection + inbound *espDirection + + initiatorSelectors []trafficSelector + responderSelectors []trafficSelector +} + +type espDirection struct { + spi uint32 + block cipher.Block + authKey []byte + integrity string + icvLength int + random io.Reader + + mu sync.Mutex + sequence uint32 + replay replayWindow +} + +type replayWindow struct { + highest uint32 + bitmap uint64 +} + +type innerPacketMetadata struct { + source net.IP + destination net.IP + protocol uint8 + sourcePort uint16 + destinationPort uint16 + nextHeader uint8 +} + +func newESPTunnel(config ChildSAConfig, randomSource io.Reader) (*espTunnel, error) { + if config.InboundSPI == 0 || config.OutboundSPI == 0 { + return nil, errors.New("ike: ESP SPIs must be nonzero") + } + if randomSource == nil { + randomSource = rand.Reader + } + outbound, err := newESPDirection( + config.OutboundSPI, + config.OutboundEncKey, + config.OutboundAuthKey, + config.Encryption, + config.Integrity, + randomSource, + ) + if err != nil { + return nil, fmt.Errorf("ike: outbound ESP: %w", err) + } + inbound, err := newESPDirection( + config.InboundSPI, + config.InboundEncKey, + config.InboundAuthKey, + config.Encryption, + config.Integrity, + randomSource, + ) + if err != nil { + return nil, fmt.Errorf("ike: inbound ESP: %w", err) + } + if len(config.InitiatorSelectors) == 0 || len(config.ResponderSelectors) == 0 { + return nil, errors.New("ike: ESP traffic selectors are required") + } + return &espTunnel{ + outbound: outbound, + inbound: inbound, + initiatorSelectors: copyESPTrafficSelectors(config.InitiatorSelectors), + responderSelectors: copyESPTrafficSelectors(config.ResponderSelectors), + }, nil +} + +func newESPDirection( + spi uint32, + encryptionKey []byte, + authenticationKey []byte, + encryption string, + integrity string, + randomSource io.Reader, +) (*espDirection, error) { + expectedEncryptionLength := 0 + switch encryption { + case "aes-cbc-128": + expectedEncryptionLength = 16 + case "aes-cbc-256": + expectedEncryptionLength = 32 + default: + return nil, fmt.Errorf("unsupported encryption suite %q", encryption) + } + if len(encryptionKey) != expectedEncryptionLength { + return nil, fmt.Errorf("AES key has length %d, want %d", len(encryptionKey), expectedEncryptionLength) + } + expectedAuthenticationLength := 0 + icvLength := 0 + switch integrity { + case "hmac-sha1-96": + expectedAuthenticationLength = sha1.Size + icvLength = 12 + case "hmac-sha2-256-128": + expectedAuthenticationLength = sha256.Size + icvLength = 16 + default: + return nil, fmt.Errorf("unsupported integrity suite %q", integrity) + } + if len(authenticationKey) != expectedAuthenticationLength { + return nil, fmt.Errorf( + "authentication key has length %d, want %d", + len(authenticationKey), + expectedAuthenticationLength, + ) + } + block, err := aes.NewCipher(encryptionKey) + if err != nil { + return nil, err + } + return &espDirection{ + spi: spi, + block: block, + authKey: append([]byte(nil), authenticationKey...), + integrity: integrity, + icvLength: icvLength, + random: randomSource, + }, nil +} + +func (tunnel *espTunnel) seal(innerPacket []byte) ([]byte, error) { + if tunnel == nil { + return nil, errors.New("ike: nil ESP tunnel") + } + metadata, err := parseInnerPacket(innerPacket) + if err != nil { + return nil, fmt.Errorf("%w: %v", errESPPolicyDrop, err) + } + if !packetAllowed( + metadata, + tunnel.initiatorSelectors, + tunnel.responderSelectors, + ) { + return nil, fmt.Errorf("%w: outbound packet is outside negotiated traffic selectors", errESPPolicyDrop) + } + return tunnel.outbound.seal(innerPacket, metadata.nextHeader) +} + +func (tunnel *espTunnel) open(packet []byte) ([]byte, error) { + if tunnel == nil { + return nil, errors.New("ike: nil ESP tunnel") + } + return tunnel.inbound.open(packet, func(innerPacket []byte, nextHeader uint8) error { + metadata, err := parseInnerPacket(innerPacket) + if err != nil { + return err + } + if metadata.nextHeader != nextHeader { + return errors.New("ike: ESP trailer does not match the inner IP version") + } + if !packetAllowed( + metadata, + tunnel.responderSelectors, + tunnel.initiatorSelectors, + ) { + return errors.New("ike: inbound packet is outside negotiated traffic selectors") + } + return nil + }) +} + +func (direction *espDirection) seal(innerPacket []byte, nextHeader uint8) ([]byte, error) { + direction.mu.Lock() + defer direction.mu.Unlock() + + if direction.sequence == math.MaxUint32 { + return nil, errors.New("ike: ESP sequence number exhausted; rekey is required") + } + direction.sequence++ + sequence := direction.sequence + + blockSize := direction.block.BlockSize() + paddingLength := (blockSize - ((len(innerPacket) + 2) % blockSize)) % blockSize + plaintext := make([]byte, len(innerPacket)+paddingLength+2) + copy(plaintext, innerPacket) + for index := 0; index < paddingLength; index++ { + plaintext[len(innerPacket)+index] = byte(index + 1) + } + plaintext[len(plaintext)-2] = byte(paddingLength) + plaintext[len(plaintext)-1] = nextHeader + + authenticatedLength := espHeaderLength + blockSize + len(plaintext) + packet := make([]byte, authenticatedLength+direction.icvLength) + binary.BigEndian.PutUint32(packet[0:4], direction.spi) + binary.BigEndian.PutUint32(packet[4:8], sequence) + iv := packet[espHeaderLength : espHeaderLength+blockSize] + if _, err := io.ReadFull(direction.random, iv); err != nil { + return nil, fmt.Errorf("ike: generate ESP IV: %w", err) + } + cipher.NewCBCEncrypter(direction.block, iv).CryptBlocks( + packet[espHeaderLength+blockSize:authenticatedLength], + plaintext, + ) + icv := direction.authenticationCode(packet[:authenticatedLength]) + copy(packet[authenticatedLength:], icv) + return packet, nil +} + +func (direction *espDirection) open( + packet []byte, + validate func([]byte, uint8) error, +) ([]byte, error) { + direction.mu.Lock() + defer direction.mu.Unlock() + + blockSize := direction.block.BlockSize() + minimumLength := espHeaderLength + blockSize + blockSize + direction.icvLength + if len(packet) < minimumLength { + return nil, errors.New("ike: ESP packet is truncated") + } + if binary.BigEndian.Uint32(packet[0:4]) != direction.spi { + return nil, errors.New("ike: ESP packet has an unexpected SPI") + } + sequence := binary.BigEndian.Uint32(packet[4:8]) + if sequence == 0 || !direction.replay.wouldAccept(sequence) { + return nil, errESPReplay + } + authenticatedLength := len(packet) - direction.icvLength + ciphertext := packet[espHeaderLength+blockSize : authenticatedLength] + if len(ciphertext) == 0 || len(ciphertext)%blockSize != 0 { + return nil, errors.New("ike: ESP ciphertext is not block aligned") + } + expectedICV := direction.authenticationCode(packet[:authenticatedLength]) + if subtle.ConstantTimeCompare(expectedICV, packet[authenticatedLength:]) != 1 { + return nil, errESPAuthentication + } + + plaintext := make([]byte, len(ciphertext)) + iv := packet[espHeaderLength : espHeaderLength+blockSize] + cipher.NewCBCDecrypter(direction.block, iv).CryptBlocks(plaintext, ciphertext) + if len(plaintext) < 2 { + return nil, errors.New("ike: ESP plaintext is truncated") + } + paddingLength := int(plaintext[len(plaintext)-2]) + if paddingLength > len(plaintext)-2 { + return nil, errors.New("ike: ESP padding length is invalid") + } + paddingStart := len(plaintext) - 2 - paddingLength + for index := 0; index < paddingLength; index++ { + if plaintext[paddingStart+index] != byte(index+1) { + return nil, errors.New("ike: ESP padding bytes are invalid") + } + } + nextHeader := plaintext[len(plaintext)-1] + if nextHeader != 4 && nextHeader != 41 { + return nil, fmt.Errorf("ike: unsupported ESP next-header value %d", nextHeader) + } + innerPacket := append([]byte(nil), plaintext[:paddingStart]...) + if validate != nil { + if err := validate(innerPacket, nextHeader); err != nil { + return nil, err + } + } + direction.replay.commit(sequence) + return innerPacket, nil +} + +func (direction *espDirection) authenticationCode(packet []byte) []byte { + var mac hashWriter + switch direction.integrity { + case "hmac-sha1-96": + mac = hmac.New(sha1.New, direction.authKey) + case "hmac-sha2-256-128": + mac = hmac.New(sha256.New, direction.authKey) + default: + panic("unreachable ESP integrity suite") + } + _, _ = mac.Write(packet) + return mac.Sum(nil)[:direction.icvLength] +} + +type hashWriter interface { + Write([]byte) (int, error) + Sum([]byte) []byte +} + +func (window replayWindow) wouldAccept(sequence uint32) bool { + if sequence == 0 { + return false + } + if window.highest == 0 || sequence > window.highest { + return true + } + difference := window.highest - sequence + if difference >= espReplayWindow { + return false + } + return window.bitmap&(uint64(1)< window.highest { + difference := sequence - window.highest + if difference >= espReplayWindow { + window.bitmap = 1 + } else { + window.bitmap = window.bitmap<> 4 { + case 4: + return parseInnerIPv4(packet) + case 6: + return parseInnerIPv6(packet) + default: + return innerPacketMetadata{}, errors.New("ike: inner packet is not IPv4 or IPv6") + } +} + +func parseInnerIPv4(packet []byte) (innerPacketMetadata, error) { + if len(packet) < 20 { + return innerPacketMetadata{}, errors.New("ike: inner IPv4 packet is truncated") + } + headerLength := int(packet[0]&0x0f) * 4 + if headerLength < 20 || headerLength > len(packet) { + return innerPacketMetadata{}, errors.New("ike: inner IPv4 header length is invalid") + } + totalLength := int(binary.BigEndian.Uint16(packet[2:4])) + if totalLength != len(packet) || totalLength < headerLength { + return innerPacketMetadata{}, errors.New("ike: inner IPv4 total length is invalid") + } + metadata := innerPacketMetadata{ + source: append(net.IP(nil), packet[12:16]...), + destination: append(net.IP(nil), packet[16:20]...), + protocol: packet[9], + nextHeader: 4, + } + fragmentOffset := binary.BigEndian.Uint16(packet[6:8]) & 0x1fff + if fragmentOffset == 0 { + parseTransportPorts(packet[headerLength:], &metadata) + } + return metadata, nil +} + +func parseInnerIPv6(packet []byte) (innerPacketMetadata, error) { + if len(packet) < 40 { + return innerPacketMetadata{}, errors.New("ike: inner IPv6 packet is truncated") + } + payloadLength := int(binary.BigEndian.Uint16(packet[4:6])) + if payloadLength+40 != len(packet) { + return innerPacketMetadata{}, errors.New("ike: inner IPv6 payload length is invalid") + } + metadata := innerPacketMetadata{ + source: append(net.IP(nil), packet[8:24]...), + destination: append(net.IP(nil), packet[24:40]...), + nextHeader: 41, + } + protocol := packet[6] + offset := 40 + firstFragment := true + for { + switch protocol { + case 0, 43, 60: + if offset+2 > len(packet) { + return innerPacketMetadata{}, errors.New("ike: inner IPv6 extension header is truncated") + } + length := (int(packet[offset+1]) + 1) * 8 + if length < 8 || offset+length > len(packet) { + return innerPacketMetadata{}, errors.New("ike: inner IPv6 extension header length is invalid") + } + protocol = packet[offset] + offset += length + case 44: + if offset+8 > len(packet) { + return innerPacketMetadata{}, errors.New("ike: inner IPv6 fragment header is truncated") + } + firstFragment = binary.BigEndian.Uint16(packet[offset+2:offset+4])&0xfff8 == 0 + protocol = packet[offset] + offset += 8 + case 51: + if offset+2 > len(packet) { + return innerPacketMetadata{}, errors.New("ike: inner IPv6 AH header is truncated") + } + length := (int(packet[offset+1]) + 2) * 4 + if length < 8 || offset+length > len(packet) { + return innerPacketMetadata{}, errors.New("ike: inner IPv6 AH header length is invalid") + } + protocol = packet[offset] + offset += length + default: + metadata.protocol = protocol + if firstFragment { + parseTransportPorts(packet[offset:], &metadata) + } + return metadata, nil + } + } +} + +func parseTransportPorts(payload []byte, metadata *innerPacketMetadata) { + if metadata == nil || (metadata.protocol != 6 && metadata.protocol != 17) || len(payload) < 4 { + return + } + metadata.sourcePort = binary.BigEndian.Uint16(payload[0:2]) + metadata.destinationPort = binary.BigEndian.Uint16(payload[2:4]) +} + +func packetAllowed( + metadata innerPacketMetadata, + sourceSelectors []trafficSelector, + destinationSelectors []trafficSelector, +) bool { + return endpointAllowed( + metadata.source, + metadata.protocol, + metadata.sourcePort, + sourceSelectors, + ) && endpointAllowed( + metadata.destination, + metadata.protocol, + metadata.destinationPort, + destinationSelectors, + ) +} + +func endpointAllowed(ip net.IP, protocol uint8, port uint16, selectors []trafficSelector) bool { + for _, selector := range selectors { + if selector.IPProtocol != 0 && selector.IPProtocol != protocol { + continue + } + if port < selector.StartPort || port > selector.EndPort { + continue + } + if ipWithinRange(ip, selector.StartIP, selector.EndIP) { + return true + } + } + return false +} + +func ipWithinRange(ip net.IP, start net.IP, end net.IP) bool { + normalizedIP, normalizedStart, normalizedEnd, ok := normalizeIPRange(ip, start, end) + if !ok { + return false + } + return bytes.Compare(normalizedIP, normalizedStart) >= 0 && + bytes.Compare(normalizedIP, normalizedEnd) <= 0 +} + +func normalizeIPRange(ip net.IP, start net.IP, end net.IP) ([]byte, []byte, []byte, bool) { + if start4 := start.To4(); start4 != nil { + ip4 := ip.To4() + end4 := end.To4() + if ip4 == nil || end4 == nil { + return nil, nil, nil, false + } + return ip4, start4, end4, true + } + ip16 := ip.To16() + start16 := start.To16() + end16 := end.To16() + if ip16 == nil || start16 == nil || end16 == nil || start.To4() != nil || end.To4() != nil { + return nil, nil, nil, false + } + return ip16, start16, end16, true +} + +func copyESPTrafficSelectors(selectors []trafficSelector) []trafficSelector { + cloned := make([]trafficSelector, len(selectors)) + for index, selector := range selectors { + cloned[index] = selector + cloned[index].StartIP = append(net.IP(nil), selector.StartIP...) + cloned[index].EndIP = append(net.IP(nil), selector.EndIP...) + } + return cloned +} diff --git a/internal/vowifi/ike/esp_test.go b/internal/vowifi/ike/esp_test.go new file mode 100644 index 0000000..00f8cc4 --- /dev/null +++ b/internal/vowifi/ike/esp_test.go @@ -0,0 +1,412 @@ +package ike + +import ( + "bytes" + "crypto/cipher" + "encoding/binary" + "encoding/hex" + "errors" + "math" + "net" + "testing" +) + +type espRepeatingReader byte + +func (value espRepeatingReader) Read(destination []byte) (int, error) { + for index := range destination { + destination[index] = byte(value) + } + return len(destination), nil +} + +func TestESPTunnelRoundTripNegotiatedSuites(t *testing.T) { + t.Parallel() + tests := []struct { + name string + encryption string + integrity string + encKey []byte + authKey []byte + }{ + { + name: "AES128-SHA1", + encryption: "aes-cbc-128", + integrity: "hmac-sha1-96", + encKey: bytes.Repeat([]byte{0x11}, 16), + authKey: bytes.Repeat([]byte{0x22}, 20), + }, + { + name: "AES256-SHA256", + encryption: "aes-cbc-256", + integrity: "hmac-sha2-256-128", + encKey: bytes.Repeat([]byte{0x33}, 32), + authKey: bytes.Repeat([]byte{0x44}, 32), + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + tunnel := mustTestESPTunnel(t, test.encryption, test.integrity, test.encKey, test.authKey) + outbound := testIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(10, 0, 0, 9), + 40123, + 50600, + []byte("REGISTER"), + ) + protected, err := tunnel.seal(outbound) + if err != nil { + t.Fatalf("seal: %v", err) + } + if got := binary.BigEndian.Uint32(protected[0:4]); got != 0x11223344 { + t.Fatalf("SPI = %#x, want %#x", got, uint32(0x11223344)) + } + if got := binary.BigEndian.Uint32(protected[4:8]); got != 1 { + t.Fatalf("sequence = %d, want 1", got) + } + + // A peer opens the outbound SA with the same SPI and key material. + peer := mustTestESPTunnel(t, test.encryption, test.integrity, test.encKey, test.authKey) + peer.initiatorSelectors, peer.responderSelectors = + peer.responderSelectors, peer.initiatorSelectors + cleartext, err := peer.open(protected) + if err != nil { + t.Fatalf("open: %v", err) + } + if !bytes.Equal(cleartext, outbound) { + t.Fatalf("round trip changed the inner packet") + } + }) + } +} + +func TestESPEncryptionMatchesRFC3602TunnelModeVector(t *testing.T) { + t.Parallel() + // RFC 3602 section 4, case #7. Authentication is intentionally outside + // that RFC vector, so this assertion covers ESP SPI/sequence/IV layout, + // tunnel-mode padding/trailer, and all 96 AES-CBC ciphertext octets. + key := mustDecodeHex(t, "0123456789abcdef0123456789abcdef") + iv := mustDecodeHex(t, "f4e765244f6407adf13dc1380f673f37") + innerPacket := mustDecodeHex(t, + "45000054090400004001f988c0a87b03c0a87bc8"+ + "08009f76a90a0100b49c083d02a20400"+ + "08090a0b0c0d0e0f1011121314151617"+ + "18191a1b1c1d1e1f2021222324252627"+ + "28292a2b2c2d2e2f3031323334353637", + ) + expectedCiphertext := mustDecodeHex(t, + "773b5241a4c449225e4f3ce5ed611b0c"+ + "237ca96cf74a93013c1b0ea1a0cf70f8"+ + "e4ecaec78ac53aad7a0f022b859243c6"+ + "47752e94a859352b8a4d4d2decd136e5"+ + "c177f132ad3fbfb2201ac9904c74ee0a"+ + "109e0ca1e4dfe9d5a100b842f1c22f0d", + ) + direction, err := newESPDirection( + 0x8765, + key, + bytes.Repeat([]byte{0x5a}, 20), + "aes-cbc-128", + "hmac-sha1-96", + bytes.NewReader(iv), + ) + if err != nil { + t.Fatal(err) + } + direction.sequence = 1 + packet, err := direction.seal(innerPacket, 4) + if err != nil { + t.Fatal(err) + } + if got := packet[:8]; !bytes.Equal(got, mustDecodeHex(t, "0000876500000002")) { + t.Fatalf("ESP header = %x", got) + } + if got := packet[8:24]; !bytes.Equal(got, iv) { + t.Fatalf("ESP IV = %x", got) + } + if got := packet[24 : 24+len(expectedCiphertext)]; !bytes.Equal(got, expectedCiphertext) { + t.Fatalf("ESP ciphertext = %x, want %x", got, expectedCiphertext) + } +} + +func TestESPRejectsTamperWrongSPIAndReplay(t *testing.T) { + t.Parallel() + tunnel := mustTestESPTunnel( + t, + "aes-cbc-128", + "hmac-sha1-96", + bytes.Repeat([]byte{0x51}, 16), + bytes.Repeat([]byte{0x61}, 20), + ) + peer := mustTestESPTunnel( + t, + "aes-cbc-128", + "hmac-sha1-96", + bytes.Repeat([]byte{0x51}, 16), + bytes.Repeat([]byte{0x61}, 20), + ) + peer.initiatorSelectors, peer.responderSelectors = + peer.responderSelectors, peer.initiatorSelectors + inner := testIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(10, 0, 0, 9), + 42000, + 50600, + []byte("payload"), + ) + protected, err := tunnel.seal(inner) + if err != nil { + t.Fatal(err) + } + + tampered := append([]byte(nil), protected...) + tampered[len(tampered)-1] ^= 0x80 + if _, err := peer.open(tampered); !errors.Is(err, errESPAuthentication) { + t.Fatalf("tampered packet error = %v, want authentication failure", err) + } + + wrongSPI := append([]byte(nil), protected...) + wrongSPI[0] ^= 0x01 + if _, err := peer.open(wrongSPI); err == nil { + t.Fatal("packet with wrong SPI was accepted") + } + + if _, err := peer.open(protected); err != nil { + t.Fatalf("first authenticated packet: %v", err) + } + if _, err := peer.open(protected); !errors.Is(err, errESPReplay) { + t.Fatalf("replayed packet error = %v, want replay rejection", err) + } +} + +func TestESPReplayWindowAcceptsAuthenticatedReordering(t *testing.T) { + t.Parallel() + sender := mustDefaultESPTunnel(t) + receiver := mustDefaultESPTunnel(t) + receiver.initiatorSelectors, receiver.responderSelectors = + receiver.responderSelectors, receiver.initiatorSelectors + var protected [][]byte + for index := 0; index < 3; index++ { + packet := testIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(10, 0, 0, 9), + uint16(40000+index), + 50600, + []byte{byte(index)}, + ) + value, err := sender.seal(packet) + if err != nil { + t.Fatal(err) + } + protected = append(protected, value) + } + for _, index := range []int{2, 0, 1} { + if _, err := receiver.open(protected[index]); err != nil { + t.Fatalf("open sequence %d: %v", index+1, err) + } + } + if _, err := receiver.open(protected[0]); !errors.Is(err, errESPReplay) { + t.Fatalf("duplicate reordered packet error = %v", err) + } +} + +func TestESPRejectsAuthenticatedInvalidPaddingWithoutConsumingSequence(t *testing.T) { + t.Parallel() + sender := mustDefaultESPTunnel(t) + receiver := mustDefaultESPTunnel(t) + receiver.initiatorSelectors, receiver.responderSelectors = + receiver.responderSelectors, receiver.initiatorSelectors + inner := testIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(10, 0, 0, 9), + 40000, + 50600, + []byte("one"), + ) + protected, err := sender.seal(inner) + if err != nil { + t.Fatal(err) + } + malformed := append([]byte(nil), protected...) + rewriteESPPlaintext(t, receiver.inbound, malformed, func(plaintext []byte) { + paddingLength := int(plaintext[len(plaintext)-2]) + if paddingLength == 0 { + plaintext[len(plaintext)-2] = 1 + plaintext[len(plaintext)-3] = 0xff + return + } + plaintext[len(plaintext)-2-paddingLength] ^= 0xff + }) + if _, err := receiver.open(malformed); err == nil { + t.Fatal("authenticated packet with invalid padding was accepted") + } + if _, err := receiver.open(protected); err != nil { + t.Fatalf("invalid packet consumed the sequence number: %v", err) + } +} + +func TestESPTrafficSelectorsAreEnforcedInBothDirections(t *testing.T) { + t.Parallel() + tunnel := mustDefaultESPTunnel(t) + disallowed := testIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(203, 0, 113, 10), + 40000, + 50600, + nil, + ) + if _, err := tunnel.seal(disallowed); err == nil { + t.Fatal("outbound packet outside responder selector was accepted") + } + + sender := mustDefaultESPTunnel(t) + receiver := mustDefaultESPTunnel(t) + receiver.initiatorSelectors, receiver.responderSelectors = + receiver.responderSelectors, receiver.initiatorSelectors + allowed := testIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(10, 0, 0, 9), + 40000, + 50600, + nil, + ) + protected, err := sender.seal(allowed) + if err != nil { + t.Fatal(err) + } + rewriteESPPlaintext(t, receiver.inbound, protected, func(plaintext []byte) { + copy(plaintext[16:20], net.IPv4(203, 0, 113, 10).To4()) + }) + if _, err := receiver.open(protected); err == nil { + t.Fatal("authenticated inbound packet outside selectors was accepted") + } +} + +func TestESPSequenceExhaustionRequiresRekey(t *testing.T) { + t.Parallel() + tunnel := mustDefaultESPTunnel(t) + tunnel.outbound.sequence = math.MaxUint32 + packet := testIPv4UDPPacket( + net.IPv4(10, 0, 0, 2), + net.IPv4(10, 0, 0, 9), + 40000, + 50600, + nil, + ) + if _, err := tunnel.seal(packet); err == nil { + t.Fatal("ESP sequence wrapped instead of requiring rekey") + } +} + +func TestParseInnerIPv6ESP(t *testing.T) { + t.Parallel() + packet := make([]byte, 40+8) + packet[0] = 0x60 + binary.BigEndian.PutUint16(packet[4:6], 8) + packet[6] = 50 + packet[7] = 64 + copy(packet[8:24], net.ParseIP("2001:db8::1").To16()) + copy(packet[24:40], net.ParseIP("2001:db8::2").To16()) + metadata, err := parseInnerPacket(packet) + if err != nil { + t.Fatal(err) + } + if metadata.protocol != 50 || metadata.nextHeader != 41 { + t.Fatalf("metadata = %+v", metadata) + } +} + +func mustDefaultESPTunnel(t *testing.T) *espTunnel { + t.Helper() + return mustTestESPTunnel( + t, + "aes-cbc-128", + "hmac-sha1-96", + bytes.Repeat([]byte{0x31}, 16), + bytes.Repeat([]byte{0x41}, 20), + ) +} + +func mustTestESPTunnel( + t *testing.T, + encryption string, + integrity string, + encryptionKey []byte, + authenticationKey []byte, +) *espTunnel { + t.Helper() + selector := func(ip net.IP) trafficSelector { + return trafficSelector{ + StartPort: 0, + EndPort: 65535, + StartIP: append(net.IP(nil), ip.To4()...), + EndIP: append(net.IP(nil), ip.To4()...), + } + } + tunnel, err := newESPTunnel(ChildSAConfig{ + InboundSPI: 0x11223344, + OutboundSPI: 0x11223344, + Encryption: encryption, + Integrity: integrity, + InboundEncKey: encryptionKey, + InboundAuthKey: authenticationKey, + OutboundEncKey: encryptionKey, + OutboundAuthKey: authenticationKey, + InitiatorSelectors: []trafficSelector{selector(net.IPv4(10, 0, 0, 2))}, + ResponderSelectors: []trafficSelector{selector(net.IPv4(10, 0, 0, 9))}, + }, espRepeatingReader(0xa5)) + if err != nil { + t.Fatal(err) + } + return tunnel +} + +func testIPv4UDPPacket( + source net.IP, + destination net.IP, + sourcePort uint16, + destinationPort uint16, + payload []byte, +) []byte { + packet := make([]byte, 20+8+len(payload)) + packet[0] = 0x45 + binary.BigEndian.PutUint16(packet[2:4], uint16(len(packet))) + packet[8] = 64 + packet[9] = 17 + copy(packet[12:16], source.To4()) + copy(packet[16:20], destination.To4()) + binary.BigEndian.PutUint16(packet[20:22], sourcePort) + binary.BigEndian.PutUint16(packet[22:24], destinationPort) + binary.BigEndian.PutUint16(packet[24:26], uint16(8+len(payload))) + copy(packet[28:], payload) + return packet +} + +func rewriteESPPlaintext( + t *testing.T, + direction *espDirection, + packet []byte, + rewrite func([]byte), +) { + t.Helper() + blockSize := direction.block.BlockSize() + authenticatedLength := len(packet) - direction.icvLength + iv := packet[espHeaderLength : espHeaderLength+blockSize] + ciphertext := packet[espHeaderLength+blockSize : authenticatedLength] + plaintext := make([]byte, len(ciphertext)) + cipher.NewCBCDecrypter(direction.block, iv).CryptBlocks(plaintext, ciphertext) + rewrite(plaintext) + cipher.NewCBCEncrypter(direction.block, iv).CryptBlocks(ciphertext, plaintext) + copy(packet[authenticatedLength:], direction.authenticationCode(packet[:authenticatedLength])) +} + +func mustDecodeHex(t *testing.T, value string) []byte { + t.Helper() + decoded, err := hex.DecodeString(value) + if err != nil { + t.Fatal(err) + } + return decoded +} diff --git a/internal/vowifi/ike/installer_linux.go b/internal/vowifi/ike/installer_linux.go new file mode 100644 index 0000000..aafcabd --- /dev/null +++ b/internal/vowifi/ike/installer_linux.go @@ -0,0 +1,358 @@ +//go:build linux + +package ike + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "net" + "os/exec" + "strconv" + "strings" + "sync" + + "vocat/internal/vowifi" +) + +type linuxXFRMInstaller struct { + ipCommand string +} + +func defaultChildSAInstaller() ChildSAInstaller { + return linuxChildSAInstallerRouter{ipCommand: "ip"} +} + +type linuxChildSAInstallerRouter struct { + ipCommand string +} + +func (router linuxChildSAInstallerRouter) Install(ctx context.Context, config ChildSAConfig) (ChildSAHandle, error) { + if config.ProxyMode == vowifi.ProxyModeSOCKS5 || config.UDPEncapsulation { + return (linuxUserspaceInstaller{ipCommand: router.ipCommand}).Install(ctx, config) + } + return (linuxXFRMInstaller{ipCommand: router.ipCommand}).Install(ctx, config) +} + +type linuxXFRMHandle struct { + mu sync.Mutex + ipCommand string + config ChildSAConfig + reqid string + closed bool +} + +func (*linuxXFRMHandle) DataplaneMode() string { return "xfrm" } + +func (installer linuxXFRMInstaller) Install(ctx context.Context, config ChildSAConfig) (ChildSAHandle, error) { + if config.ProxyMode == vowifi.ProxyModeSOCKS5 || config.UDPEncapsulation { + return nil, errors.New("NAT-T and SOCKS5 require a user-space ESP/TUN installer using NATTPacketRelay; kernel XFRM cannot own the user-space UDP association") + } + if config.OuterLocal == nil || config.OuterRemote == nil { + return nil, errors.New("outer IP addresses are required") + } + if config.InboundSPI == 0 || config.OutboundSPI == 0 { + return nil, errors.New("ESP SPIs must be nonzero") + } + command := installer.ipCommand + if command == "" { + command = "ip" + } + if _, err := exec.LookPath(command); err != nil { + return nil, errors.New("Linux iproute2 is required to install the CHILD_SA") + } + handle := &linuxXFRMHandle{ + ipCommand: command, + config: cloneChildSAConfig(config), + reqid: strconv.FormatUint(uint64(config.InboundSPI), 10), + } + if err := handle.install(ctx); err != nil { + _ = handle.Close(context.Background()) + return nil, err + } + return handle, nil +} + +func (handle *linuxXFRMHandle) install(ctx context.Context) error { + config := handle.config + if err := handle.run(ctx, "create tunnel interface", "link", "add", config.Name, "type", "dummy"); err != nil { + return err + } + if config.InnerLocalIPv4 != nil { + if err := handle.run(ctx, "assign tunnel IPv4 address", "address", "add", config.InnerLocalIPv4.String()+"/32", "dev", config.Name); err != nil { + return err + } + } + if config.InnerLocalIPv6 != nil { + if err := handle.run(ctx, "assign tunnel IPv6 address", "-6", "address", "add", fmt.Sprintf("%s/%d", config.InnerLocalIPv6.String(), config.InnerIPv6Prefix), "dev", config.Name); err != nil { + return err + } + } + if err := handle.run(ctx, "enable tunnel interface", "link", "set", "dev", config.Name, "up"); err != nil { + return err + } + outboundState := handle.stateArguments( + config.OuterLocal, config.OuterRemote, config.OutboundSPI, + config.OutboundEncKey, config.OutboundAuthKey, + ) + if err := handle.run(ctx, "install outbound ESP state", append([]string{"xfrm", "state", "add"}, outboundState...)...); err != nil { + return err + } + inboundState := handle.stateArguments( + config.OuterRemote, config.OuterLocal, config.InboundSPI, + config.InboundEncKey, config.InboundAuthKey, + ) + if err := handle.run(ctx, "install inbound ESP state", append([]string{"xfrm", "state", "add"}, inboundState...)...); err != nil { + return err + } + for _, initiator := range config.InitiatorSelectors { + for _, responder := range config.ResponderSelectors { + if (initiator.StartIP.To4() == nil) != (responder.StartIP.To4() == nil) { + continue + } + if err := handle.installPolicyPair(ctx, initiator, responder); err != nil { + return err + } + } + } + return nil +} + +func (handle *linuxXFRMHandle) installPolicyPair( + ctx context.Context, + initiator trafficSelector, + responder trafficSelector, +) error { + initiatorPrefix, err := selectorPrefix(initiator) + if err != nil { + return err + } + responderPrefix, err := selectorPrefix(responder) + if err != nil { + return err + } + if initiator.IPProtocol != responder.IPProtocol && + initiator.IPProtocol != 0 && responder.IPProtocol != 0 { + return errors.New("negotiated traffic selectors use conflicting IP protocols") + } + protocol := initiator.IPProtocol + if protocol == 0 { + protocol = responder.IPProtocol + } + family := "-4" + if initiator.StartIP.To4() == nil { + family = "-6" + } + outbound := []string{ + family, "xfrm", "policy", "add", + "src", initiatorPrefix, "dst", responderPrefix, "dir", "out", + } + inbound := []string{ + family, "xfrm", "policy", "add", + "src", responderPrefix, "dst", initiatorPrefix, "dir", "in", + } + if protocol != 0 { + outbound = append(outbound, "proto", strconv.Itoa(int(protocol))) + inbound = append(inbound, "proto", strconv.Itoa(int(protocol))) + } + outbound, err = appendSelectorPorts(outbound, initiator, responder) + if err != nil { + return err + } + inbound, err = appendSelectorPorts(inbound, responder, initiator) + if err != nil { + return err + } + outbound = append(outbound, + "tmpl", "src", handle.config.OuterLocal.String(), "dst", handle.config.OuterRemote.String(), + "proto", "esp", "mode", "tunnel", "reqid", handle.reqid, + ) + inbound = append(inbound, + "tmpl", "src", handle.config.OuterRemote.String(), "dst", handle.config.OuterLocal.String(), + "proto", "esp", "mode", "tunnel", "reqid", handle.reqid, + ) + if err := handle.run(ctx, "install outbound ESP policy", outbound...); err != nil { + return err + } + return handle.run(ctx, "install inbound ESP policy", inbound...) +} + +func appendSelectorPorts( + arguments []string, + source trafficSelector, + destination trafficSelector, +) ([]string, error) { + appendPort := func(label string, start uint16, end uint16) error { + if start == 0 && end == 65535 { + return nil + } + if start != end { + return fmt.Errorf("negotiated %s port range %d-%d cannot be represented safely by XFRM", label, start, end) + } + arguments = append(arguments, label, strconv.Itoa(int(start))) + return nil + } + if err := appendPort("sport", source.StartPort, source.EndPort); err != nil { + return nil, err + } + if err := appendPort("dport", destination.StartPort, destination.EndPort); err != nil { + return nil, err + } + return arguments, nil +} + +func selectorPrefix(selector trafficSelector) (string, error) { + start := selector.StartIP + end := selector.EndIP + bits := 128 + if start4 := start.To4(); start4 != nil { + start = start4 + end = end.To4() + bits = 32 + } else { + start = start.To16() + end = end.To16() + } + if start == nil || end == nil || len(start) != len(end) { + return "", errors.New("negotiated traffic selector IP range is invalid") + } + prefix := 0 + different := false + for index := 0; index < len(start); index++ { + for bit := 7; bit >= 0; bit-- { + startBit := start[index] & (1 << bit) + endBit := end[index] & (1 << bit) + if !different && startBit == endBit { + prefix++ + continue + } + different = true + if startBit != 0 || endBit == 0 { + return "", errors.New("negotiated traffic selector range is not a CIDR prefix") + } + } + } + network := &net.IPNet{IP: start, Mask: net.CIDRMask(prefix, bits)} + return network.String(), nil +} + +func (handle *linuxXFRMHandle) stateArguments( + source net.IP, + destination net.IP, + spi uint32, + encryptionKey []byte, + integrityKey []byte, +) []string { + arguments := []string{ + "src", source.String(), + "dst", destination.String(), + "proto", "esp", + "spi", fmt.Sprintf("0x%08x", spi), + "reqid", handle.reqid, + "mode", "tunnel", + } + switch handle.config.Integrity { + case "hmac-sha1-96": + arguments = append(arguments, "auth-trunc", "hmac(sha1)", "0x"+hex.EncodeToString(integrityKey), "96") + case "hmac-sha2-256-128": + arguments = append(arguments, "auth-trunc", "hmac(sha256)", "0x"+hex.EncodeToString(integrityKey), "128") + } + arguments = append(arguments, "enc", "cbc(aes)", "0x"+hex.EncodeToString(encryptionKey)) + if handle.config.UDPEncapsulation { + arguments = append(arguments, "encap", "espinudp", "4500", "4500", "0.0.0.0") + } + return arguments +} + +func (handle *linuxXFRMHandle) run(ctx context.Context, operation string, arguments ...string) error { + command := exec.CommandContext(ctx, handle.ipCommand, arguments...) + output, err := command.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("%s: %s", operation, message) + } + return nil +} + +func (handle *linuxXFRMHandle) Close(ctx context.Context) error { + handle.mu.Lock() + defer handle.mu.Unlock() + if handle.closed { + return nil + } + handle.closed = true + config := handle.config + var errs []error + deletePolicy := func(family, source, destination, direction string) { + command := exec.CommandContext(ctx, handle.ipCommand, + family, "xfrm", "policy", "delete", + "src", source, "dst", destination, "dir", direction, + ) + if err := command.Run(); err != nil { + errs = append(errs, err) + } + } + for _, initiator := range config.InitiatorSelectors { + for _, responder := range config.ResponderSelectors { + if (initiator.StartIP.To4() == nil) != (responder.StartIP.To4() == nil) { + continue + } + initiatorPrefix, initiatorErr := selectorPrefix(initiator) + responderPrefix, responderErr := selectorPrefix(responder) + if initiatorErr != nil || responderErr != nil { + continue + } + family := "-4" + if initiator.StartIP.To4() == nil { + family = "-6" + } + deletePolicy(family, initiatorPrefix, responderPrefix, "out") + deletePolicy(family, responderPrefix, initiatorPrefix, "in") + } + } + deleteState := func(source net.IP, destination net.IP, spi uint32) { + command := exec.CommandContext(ctx, handle.ipCommand, + "xfrm", "state", "delete", + "src", source.String(), "dst", destination.String(), + "proto", "esp", "spi", fmt.Sprintf("0x%08x", spi), + ) + if err := command.Run(); err != nil { + errs = append(errs, err) + } + } + deleteState(config.OuterLocal, config.OuterRemote, config.OutboundSPI) + deleteState(config.OuterRemote, config.OuterLocal, config.InboundSPI) + if err := exec.CommandContext(ctx, handle.ipCommand, "link", "delete", config.Name).Run(); err != nil { + errs = append(errs, err) + } + return errors.Join(errs...) +} + +func cloneChildSAConfig(config ChildSAConfig) ChildSAConfig { + config.OuterLocal = append(net.IP(nil), config.OuterLocal...) + config.OuterRemote = append(net.IP(nil), config.OuterRemote...) + config.InnerLocalIPv4 = append(net.IP(nil), config.InnerLocalIPv4...) + config.InnerLocalIPv6 = append(net.IP(nil), config.InnerLocalIPv6...) + config.InboundEncKey = append([]byte(nil), config.InboundEncKey...) + config.InboundAuthKey = append([]byte(nil), config.InboundAuthKey...) + config.OutboundEncKey = append([]byte(nil), config.OutboundEncKey...) + config.OutboundAuthKey = append([]byte(nil), config.OutboundAuthKey...) + config.InitiatorSelectors = cloneTrafficSelectors(config.InitiatorSelectors) + config.ResponderSelectors = cloneTrafficSelectors(config.ResponderSelectors) + config.PCSCF = cloneIPs(config.PCSCF) + config.DNS = cloneIPs(config.DNS) + return config +} + +func cloneTrafficSelectors(selectors []trafficSelector) []trafficSelector { + result := append([]trafficSelector(nil), selectors...) + for index := range result { + result[index].StartIP = append(net.IP(nil), result[index].StartIP...) + result[index].EndIP = append(net.IP(nil), result[index].EndIP...) + } + return result +} diff --git a/internal/vowifi/ike/installer_other.go b/internal/vowifi/ike/installer_other.go new file mode 100644 index 0000000..95ce3f2 --- /dev/null +++ b/internal/vowifi/ike/installer_other.go @@ -0,0 +1,18 @@ +//go:build !linux + +package ike + +import ( + "context" + "errors" +) + +type unsupportedInstaller struct{} + +func defaultChildSAInstaller() ChildSAInstaller { + return unsupportedInstaller{} +} + +func (unsupportedInstaller) Install(context.Context, ChildSAConfig) (ChildSAHandle, error) { + return nil, errors.New("kernel CHILD_SA installation is supported only on Linux") +} diff --git a/internal/vowifi/ike/provider.go b/internal/vowifi/ike/provider.go new file mode 100644 index 0000000..78bec5f --- /dev/null +++ b/internal/vowifi/ike/provider.go @@ -0,0 +1,885 @@ +package ike + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io" + "net" + "strings" + "sync" + "time" + + "vocat/internal/vowifi" +) + +type Config struct { + Random io.Reader + Resolver *net.Resolver + Dialer *net.Dialer + RootCAs *x509.CertPool + ResponderPublicKey crypto.PublicKey + ServerName string + Timeout time.Duration + KeepaliveInterval time.Duration + Installer ChildSAInstaller + IdentityType uint8 + APN string +} + +type Provider struct { + config Config + transportFactory func(context.Context, transportConfig, vowifi.ProxyRoute, string) (datagramTransport, error) +} + +func NewProvider(config Config) (*Provider, error) { + if config.Random == nil { + config.Random = rand.Reader + } + if config.Resolver == nil { + config.Resolver = net.DefaultResolver + } + if config.Dialer == nil { + config.Dialer = &net.Dialer{} + } + if config.Timeout < 0 { + return nil, errors.New("ike: timeout must not be negative") + } + if config.Timeout == 0 { + config.Timeout = 12 * time.Second + } + if config.KeepaliveInterval < 0 { + return nil, errors.New("ike: keepalive interval must not be negative") + } + if config.KeepaliveInterval == 0 { + config.KeepaliveInterval = 20 * time.Second + } + if config.IdentityType == 0 { + config.IdentityType = 3 // ID_RFC822_ADDR, carrying the permanent NAI. + } + config.APN = strings.TrimSpace(config.APN) + if config.APN == "" { + config.APN = "ims" + } + if len(config.APN) > 253 || strings.ContainsAny(config.APN, " \t\r\n/:@") { + return nil, errors.New("ike: APN is invalid") + } + if config.Installer == nil { + config.Installer = defaultChildSAInstaller() + } + return &Provider{config: config, transportFactory: newDatagramTransport}, nil +} + +func (provider *Provider) Start(ctx context.Context, request vowifi.TunnelRequest) (vowifi.TunnelSession, error) { + if provider == nil { + return nil, errors.New("ike: nil provider") + } + if ctx == nil { + ctx = context.Background() + } + if request.AKA == nil { + return nil, errors.New("ike: AKA provider is required") + } + epdg := strings.TrimSpace(request.EPDG) + if epdg == "" || strings.ContainsAny(epdg, " \t\r\n/:") { + return nil, errors.New("ike: ePDG must be a hostname") + } + aka, err := newAKAClient(request.Identity, request.AKA) + if err != nil { + return nil, err + } + transport, err := provider.transportFactory(ctx, transportConfig{ + Resolver: provider.config.Resolver, + Dialer: provider.config.Dialer, + Timeout: provider.config.Timeout, + }, request.Proxy, epdg) + if err != nil { + return nil, err + } + closeTransport := true + defer func() { + if closeTransport { + _ = transport.Close() + } + }() + + group := uint16(dhMODP2048) + legacyFirst := request.Identity.HomeMCC == "234" && request.Identity.HomeMNC == "15" + if legacyFirst { + group = dhMODP1024 + } + dh, err := newDHExchange(group, provider.config.Random) + if err != nil { + return nil, err + } + var initiatorSPI [8]byte + if err := fillNonzero(provider.config.Random, initiatorSPI[:]); err != nil { + return nil, err + } + initiatorNonce := make([]byte, 32) + if _, err := io.ReadFull(provider.config.Random, initiatorNonce); err != nil { + return nil, fmt.Errorf("ike: generate initiator nonce: %w", err) + } + ikeProposalBody, err := marshalProposals([]proposal{ikeOffer(group, legacyFirst)}) + if err != nil { + return nil, err + } + keBody := make([]byte, 4+len(dh.Public)) + binary.BigEndian.PutUint16(keBody[0:2], group) + copy(keBody[4:], dh.Public) + localAddress := transport.LocalAddr() + remoteAddress := transport.RemoteAddr() + if localAddress == nil || remoteAddress == nil { + return nil, errors.New("ike: transport did not expose UDP endpoints") + } + sourceHash, err := natDetectionHash(initiatorSPI, [8]byte{}, localAddress.IP, uint16(localAddress.Port)) + if err != nil { + return nil, err + } + destinationHash, err := natDetectionHash(initiatorSPI, [8]byte{}, remoteAddress.IP, uint16(remoteAddress.Port)) + if err != nil { + return nil, err + } + initPayloads := []payload{ + {Type: payloadSA, Body: ikeProposalBody}, + {Type: payloadKE, Body: keBody}, + {Type: payloadNonce, Body: initiatorNonce}, + makeNotify(notifyNATSource, sourceHash), + makeNotify(notifyNATDestination, destinationHash), + } + first, initBody, err := marshalPayloadChain(initPayloads) + if err != nil { + return nil, err + } + initRequest := ikeHeader{ + InitiatorSPI: initiatorSPI, + NextPayload: first, + Exchange: exchangeIKEInit, + Flags: flagInitiator, + MessageID: 0, + }.marshal(initBody) + initResponse, err := transport.RoundTrip(ctx, initRequest) + if err != nil { + return nil, err + } + responseHeader, responseBody, err := validateResponse(initResponse, initiatorSPI, [8]byte{}, exchangeIKEInit, 0) + if err != nil { + return nil, err + } + if responseHeader.ResponderSPI == [8]byte{} { + return nil, errors.New("ike: responder returned a zero SPI") + } + initResponsePayloads, err := parsePayloadChain(responseHeader.NextPayload, responseBody) + if err != nil { + return nil, err + } + if err := rejectFatalNotifications(initResponsePayloads); err != nil { + return nil, err + } + saPayload, err := onePayload(initResponsePayloads, payloadSA) + if err != nil { + return nil, err + } + selectedProposals, err := parseProposals(saPayload.Body) + if err != nil || len(selectedProposals) != 1 { + return nil, errors.New("ike: responder did not select exactly one IKE proposal") + } + ikeSuite, err := parseIKESuite(selectedProposals[0]) + if err != nil { + return nil, err + } + if ikeSuite.DHID != group { + return nil, fmt.Errorf("ike: responder selected DH group %d but KE used group %d", ikeSuite.DHID, group) + } + responderKE, err := onePayload(initResponsePayloads, payloadKE) + if err != nil { + return nil, err + } + if len(responderKE.Body) < 4 || binary.BigEndian.Uint16(responderKE.Body[0:2]) != group { + return nil, errors.New("ike: responder KE group does not match the selected proposal") + } + sharedSecret, err := dh.shared(responderKE.Body[4:]) + if err != nil { + return nil, err + } + responderNoncePayload, err := onePayload(initResponsePayloads, payloadNonce) + if err != nil { + return nil, err + } + if len(responderNoncePayload.Body) < 16 || len(responderNoncePayload.Body) > 256 { + return nil, errors.New("ike: responder nonce length is outside 16..256 bytes") + } + responderNonce := responderNoncePayload.Body + keys, err := deriveIKEKeys( + ikeSuite, + sharedSecret, + initiatorNonce, + responderNonce, + initiatorSPI, + responseHeader.ResponderSPI, + ) + if err != nil { + return nil, err + } + natDetected, err := detectNAT( + initResponsePayloads, + initiatorSPI, + responseHeader.ResponderSPI, + transport.LocalAddr(), + transport.RemoteAddr(), + ) + if err != nil { + return nil, err + } + if request.Proxy.Mode == vowifi.ProxyModeSOCKS5 { + natDetected = true + } + if natDetected { + if err := transport.Float(ctx); err != nil { + return nil, err + } + } + + var childInboundSPIBytes [4]byte + if err := fillNonzero(provider.config.Random, childInboundSPIBytes[:]); err != nil { + return nil, err + } + childInboundSPI := binary.BigEndian.Uint32(childInboundSPIBytes[:]) + childOfferBody, err := marshalProposals([]proposal{espOffer(childInboundSPIBytes[:], legacyFirst)}) + if err != nil { + return nil, err + } + idi := payload{Type: payloadIDi, Body: append([]byte{provider.config.IdentityType, 0, 0, 0}, aka.identity...)} + requestedIDr := payload{Type: payloadIDr, Body: append([]byte{2, 0, 0, 0}, []byte(provider.config.APN)...)} + tsi := dualStackTrafficSelectors(payloadTSi) + tsr := dualStackTrafficSelectors(payloadTSr) + firstAuthPayloads := buildInitialEAPOnlyAuth(idi, requestedIDr, childOfferBody, tsi, tsr) + authHeader := ikeHeader{ + InitiatorSPI: initiatorSPI, + ResponderSPI: responseHeader.ResponderSPI, + Exchange: exchangeIKEAuth, + Flags: flagInitiator, + MessageID: 1, + } + authRequest, err := encryptPayloads(authHeader, firstAuthPayloads, ikeSuite, keys.SKei, keys.SKai, provider.config.Random) + if err != nil { + return nil, err + } + authResponse, err := transport.RoundTrip(ctx, authRequest) + if err != nil { + return nil, err + } + authResponseHeader, authResponsePayloads, err := decryptAndValidate( + authResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, 1, ikeSuite, keys, + ) + if err != nil { + return nil, err + } + _ = authResponseHeader + serverName := strings.TrimSpace(provider.config.ServerName) + if serverName == "" { + serverName = epdg + } + responderAUTH, responderID, err := validateInitialResponderAUTH( + authResponsePayloads, + initResponse, + initiatorNonce, + ikeSuite, + keys.SKpr, + serverName, + serverName, + provider.config.RootCAs, + provider.config.ResponderPublicKey, + true, // RFC 5998 EAP-only authentication defers responder AUTH. + ) + if err != nil { + return nil, err + } + messageID := uint32(1) + currentPayloads := authResponsePayloads + for round := 0; round < 10; round++ { + eapPayload, err := onePayload(currentPayloads, payloadEAP) + if err != nil { + return nil, fmt.Errorf("ike: IKE_AUTH EAP round %d: %w", round+1, err) + } + action, err := aka.handle(ctx, eapPayload.Body) + if err != nil { + return nil, err + } + if action.Success { + break + } + if len(action.Response) == 0 { + return nil, errors.New("ike: EAP state machine produced no response") + } + messageID++ + eapRequest, err := encryptPayloads(ikeHeader{ + InitiatorSPI: initiatorSPI, + ResponderSPI: responseHeader.ResponderSPI, + Exchange: exchangeIKEAuth, + Flags: flagInitiator, + MessageID: messageID, + }, []payload{{Type: payloadEAP, Body: action.Response}}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random) + if err != nil { + return nil, err + } + eapResponse, err := transport.RoundTrip(ctx, eapRequest) + if err != nil { + return nil, err + } + _, currentPayloads, err = decryptAndValidate( + eapResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys, + ) + if err != nil { + return nil, err + } + if round == 9 { + return nil, errors.New("ike: EAP exchange exceeded ten IKE_AUTH rounds") + } + } + if !aka.challengeComplete || len(aka.keys.MSK) != 64 { + return nil, errors.New("ike: EAP-AKA did not produce an authenticated MSK") + } + initiatorAUTH, err := makeEAPInitiatorAUTH( + aka.keys.MSK, + initRequest, + responderNonce, + ikeSuite, + keys.SKpi, + idi, + ) + if err != nil { + return nil, err + } + messageID++ + finalRequest, err := encryptPayloads(ikeHeader{ + InitiatorSPI: initiatorSPI, + ResponderSPI: responseHeader.ResponderSPI, + Exchange: exchangeIKEAuth, + Flags: flagInitiator, + MessageID: messageID, + }, []payload{initiatorAUTH}, ikeSuite, keys.SKei, keys.SKai, provider.config.Random) + if err != nil { + return nil, err + } + finalResponse, err := transport.RoundTrip(ctx, finalRequest) + if err != nil { + return nil, err + } + _, finalPayloads, err := decryptAndValidate( + finalResponse, initiatorSPI, responseHeader.ResponderSPI, exchangeIKEAuth, messageID, ikeSuite, keys, + ) + if err != nil { + return nil, err + } + if err := rejectFatalNotifications(finalPayloads); err != nil { + return nil, err + } + finalAUTHs := payloadsOfType(finalPayloads, payloadAuth) + if len(finalAUTHs) != 1 { + return nil, fmt.Errorf("%w: final EAP-only response must contain exactly one MSK AUTH payload", vowifi.ErrResponderAUTHRequired) + } + if len(responderID.Body) == 0 { + return nil, errors.New("ike: EAP-only exchange has no initial ePDG IDr for the responder AUTH transcript") + } + finalIDs := payloadsOfType(finalPayloads, payloadIDr) + if len(finalIDs) > 1 { + return nil, errors.New("ike: duplicate final responder IDr payload") + } + if len(finalIDs) == 1 { + if err := validateFQDNIDr(finalIDs[0], provider.config.APN, "final APN"); err != nil { + return nil, fmt.Errorf("ike: final APN IDr: %w", err) + } + } + if err := verifyEAPResponderAUTH( + finalAUTHs[0], + aka.keys.MSK, + initResponse, + initiatorNonce, + ikeSuite, + keys.SKpr, + responderID, + ); err != nil { + return nil, err + } + responderAUTH = vowifi.ResponderAUTHVerified + + childSA, err := onePayload(finalPayloads, payloadSA) + if err != nil { + return nil, err + } + childProposals, err := parseProposals(childSA.Body) + if err != nil || len(childProposals) != 1 { + return nil, errors.New("ike: responder did not select exactly one ESP proposal") + } + childSuite, err := parseESPSuite(childProposals[0]) + if err != nil { + return nil, err + } + childOutboundSPI := binary.BigEndian.Uint32(childProposals[0].SPI) + finalTSi, err := onePayload(finalPayloads, payloadTSi) + if err != nil { + return nil, err + } + finalTSr, err := onePayload(finalPayloads, payloadTSr) + if err != nil { + return nil, err + } + initiatorSelectors, err := parseTrafficSelectors(finalTSi) + if err != nil { + return nil, err + } + responderSelectors, err := parseTrafficSelectors(finalTSr) + if err != nil { + return nil, err + } + cpPayload, err := onePayload(finalPayloads, payloadCP) + if err != nil { + return nil, err + } + network, err := parseConfiguration(cpPayload) + if err != nil { + return nil, err + } + if network.LocalIPv4 == nil && network.LocalIPv6 == nil { + return nil, errors.New("ike: responder did not assign an inner IP address") + } + network.PCSCF = pcscfForAssignedFamilies( + network.PCSCF, + network.LocalIPv4 != nil, + network.LocalIPv6 != nil, + ) + if len(network.PCSCF) == 0 { + return nil, errors.New("ike: responder did not provide a P-CSCF matching an assigned address family") + } + outboundEncryption, outboundIntegrity, inboundEncryption, inboundIntegrity, err := deriveChildSAKeys( + ikeSuite, childSuite, keys.SKd, initiatorNonce, responderNonce, + ) + if err != nil { + return nil, err + } + encryptionName, integrityName := espSuiteNames(childSuite) + name := tunnelName(request.DeviceID) + relay := newSessionRelay( + transport, + ikeSuite, + keys, + initiatorSPI, + responseHeader.ResponderSPI, + natDetected, + provider.config.KeepaliveInterval, + ) + installed, err := provider.config.Installer.Install(ctx, ChildSAConfig{ + Name: name, + OuterLocal: append(net.IP(nil), transport.LocalAddr().IP...), + OuterRemote: append(net.IP(nil), transport.RemoteAddr().IP...), + InnerLocalIPv4: append(net.IP(nil), network.LocalIPv4...), + InnerLocalIPv6: append(net.IP(nil), network.LocalIPv6...), + InnerIPv6Prefix: network.IPv6Prefix, + PCSCF: cloneIPs(network.PCSCF), + DNS: cloneIPs(network.DNS), + InboundSPI: childInboundSPI, + OutboundSPI: childOutboundSPI, + Encryption: encryptionName, + Integrity: integrityName, + InboundEncKey: inboundEncryption, + InboundAuthKey: inboundIntegrity, + OutboundEncKey: outboundEncryption, + OutboundAuthKey: outboundIntegrity, + InitiatorSelectors: initiatorSelectors, + ResponderSelectors: responderSelectors, + UDPEncapsulation: natDetected, + ProxyMode: request.Proxy.Mode, + Relay: relay, + }) + if err != nil { + _ = relay.Close() + return nil, fmt.Errorf("ike: install CHILD_SA: %w", err) + } + if installed == nil { + _ = relay.Close() + return nil, errors.New("ike: CHILD_SA installer returned a nil handle") + } + dataplaneMode := "unknown" + if mode, ok := installed.(DataplaneEvidence); ok { + switch mode.DataplaneMode() { + case "userspace", "xfrm": + dataplaneMode = mode.DataplaneMode() + } + } + evidence := vowifi.TunnelEvidence{ + Established: true, + Name: name, + DataplaneMode: dataplaneMode, + LocalIPv4: ipString(network.LocalIPv4), + LocalIPv6: ipString(network.LocalIPv6), + PCSCF: ipStrings(network.PCSCF), + ResponderAUTH: responderAUTH, + IKEEncryption: fmt.Sprintf("aes-cbc-%d", ikeSuite.EncryptionBits), + IKEIntegrity: ikeIntegrityName(ikeSuite.IntegrityID), + IKEDHGroup: dhName(ikeSuite.DHID), + ESPEncryption: encryptionName, + ESPIntegrity: integrityName, + } + session := &Session{ + evidence: evidence, + network: NetworkEvidence{ + LocalIPv4: ipString(network.LocalIPv4), + LocalIPv6: ipString(network.LocalIPv6), + DNS: ipStrings(network.DNS), + PCSCF: ipStrings(network.PCSCF), + DataplaneMode: dataplaneMode, + }, + child: installed, + relay: relay, + transport: transport, + } + closeTransport = false + return session, nil +} + +func buildInitialEAPOnlyAuth( + idi payload, + requestedIDr payload, + childOfferBody []byte, + tsi payload, + tsr payload, +) []payload { + return []payload{ + idi, + requestedIDr, + makeNotify(notifyEAPOnlyAuth, nil), + {Type: payloadSA, Body: append([]byte(nil), childOfferBody...)}, + tsi, + tsr, + configurationRequest(), + } +} + +func ikeOffer(group uint16, legacyFirst bool) proposal { + transforms := []transform{ + {Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128}, + {Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 256}, + {Type: transformPRF, ID: prfHMACSHA1}, + {Type: transformPRF, ID: prfHMACSHA256}, + {Type: transformIntegrity, ID: integrityHMACSHA1_96}, + {Type: transformIntegrity, ID: integrityHMACSHA256_128}, + {Type: transformDH, ID: group}, + } + if !legacyFirst { + transforms[2], transforms[3] = transforms[3], transforms[2] + transforms[4], transforms[5] = transforms[5], transforms[4] + } + return proposal{Number: 1, Protocol: protocolIKE, Transforms: transforms} +} + +func espOffer(spi []byte, legacyFirst bool) proposal { + transforms := []transform{ + {Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128}, + {Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 256}, + {Type: transformIntegrity, ID: integrityHMACSHA1_96}, + {Type: transformIntegrity, ID: integrityHMACSHA256_128}, + {Type: transformESN, ID: 0}, + } + if !legacyFirst { + transforms[2], transforms[3] = transforms[3], transforms[2] + } + return proposal{Number: 1, Protocol: protocolESP, SPI: append([]byte(nil), spi...), Transforms: transforms} +} + +func validateResponse( + packet []byte, + initiatorSPI [8]byte, + responderSPI [8]byte, + exchange uint8, + messageID uint32, +) (ikeHeader, []byte, error) { + header, body, err := parseIKEPacket(packet) + if err != nil { + return ikeHeader{}, nil, err + } + if header.InitiatorSPI != initiatorSPI || + (responderSPI != [8]byte{} && header.ResponderSPI != responderSPI) || + header.Exchange != exchange || + header.MessageID != messageID || + header.Flags&flagResponse == 0 || + header.Flags&flagInitiator != 0 { + return ikeHeader{}, nil, fmt.Errorf("%w: response header does not match the request", errUnexpectedPacket) + } + return header, body, nil +} + +func decryptAndValidate( + packet []byte, + initiatorSPI [8]byte, + responderSPI [8]byte, + exchange uint8, + messageID uint32, + suite negotiatedSuite, + keys ikeKeys, +) (ikeHeader, []payload, error) { + header, payloads, err := decryptPayloads(packet, 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 rejectFatalNotifications(payloads []payload) error { + for _, item := range payloadsOfType(payloads, payloadNotify) { + kind, data, err := parseNotify(item) + if err != nil { + return err + } + switch kind { + case notifyNoProposal: + return errors.New("ike: responder reported NO_PROPOSAL_CHOSEN") + case notifyInvalidKE: + if len(data) == 2 { + return fmt.Errorf("ike: responder requires DH group %d", binary.BigEndian.Uint16(data)) + } + return errors.New("ike: responder reported INVALID_KE_PAYLOAD") + } + if kind < 16384 { + return fmt.Errorf("ike: responder reported fatal notification %d", kind) + } + } + return nil +} + +func detectNAT( + payloads []payload, + initiatorSPI [8]byte, + responderSPI [8]byte, + local *net.UDPAddr, + remote *net.UDPAddr, +) (bool, error) { + var sourceValue, destinationValue []byte + for _, item := range payloadsOfType(payloads, payloadNotify) { + kind, data, err := parseNotify(item) + if err != nil { + return false, err + } + switch kind { + case notifyNATSource: + sourceValue = data + case notifyNATDestination: + destinationValue = data + } + } + if len(sourceValue) == 0 && len(destinationValue) == 0 { + return false, nil + } + if len(sourceValue) != sha1Size || len(destinationValue) != sha1Size { + return false, errors.New("ike: NAT detection notification has an invalid hash length") + } + expectedSource, err := natDetectionHash(initiatorSPI, responderSPI, remote.IP, uint16(remote.Port)) + if err != nil { + return false, err + } + expectedDestination, err := natDetectionHash(initiatorSPI, responderSPI, local.IP, uint16(local.Port)) + if err != nil { + return false, err + } + return !equalBytes(sourceValue, expectedSource) || !equalBytes(destinationValue, expectedDestination), nil +} + +const sha1Size = 20 + +func equalBytes(first, second []byte) bool { + if len(first) != len(second) { + return false + } + var difference byte + for index := range first { + difference |= first[index] ^ second[index] + } + return difference == 0 +} + +func fillNonzero(random io.Reader, destination []byte) error { + for attempt := 0; attempt < 8; attempt++ { + if _, err := io.ReadFull(random, destination); err != nil { + return fmt.Errorf("ike: generate SPI: %w", err) + } + var aggregate byte + for _, value := range destination { + aggregate |= value + } + if aggregate != 0 { + return nil + } + } + return errors.New("ike: random source generated a zero SPI repeatedly") +} + +func tunnelName(deviceID string) string { + // IFNAMSIZ leaves 15 visible bytes. Hash the complete stable device ID so + // devices with the same long USB/product prefix never collide at TUNSETIFF. + normalized := strings.ToLower(strings.TrimSpace(deviceID)) + digest := sha256.Sum256([]byte(normalized)) + return "vocat" + hex.EncodeToString(digest[:5]) +} + +func ipString(ip net.IP) string { + if ip == nil { + return "" + } + return ip.String() +} + +func ipStrings(ips []net.IP) []string { + result := make([]string, 0, len(ips)) + for _, ip := range ips { + if value := ipString(ip); value != "" { + result = append(result, value) + } + } + return result +} + +func pcscfForAssignedFamilies(ips []net.IP, hasIPv4 bool, hasIPv6 bool) []net.IP { + result := make([]net.IP, 0, len(ips)) + seen := make(map[string]struct{}, len(ips)) + for _, ip := range ips { + key := ip.String() + if _, duplicate := seen[key]; duplicate { + continue + } + switch { + case ip.To4() != nil && hasIPv4: + result = append(result, append(net.IP(nil), ip...)) + seen[key] = struct{}{} + case ip.To4() == nil && ip.To16() != nil && hasIPv6: + result = append(result, append(net.IP(nil), ip...)) + seen[key] = struct{}{} + } + } + return result +} + +func cloneIPs(ips []net.IP) []net.IP { + result := make([]net.IP, 0, len(ips)) + for _, ip := range ips { + result = append(result, append(net.IP(nil), ip...)) + } + return result +} + +func ikeIntegrityName(identifier uint16) string { + switch identifier { + case integrityHMACSHA1_96: + return "hmac-sha1-96" + case integrityHMACSHA256_128: + return "hmac-sha2-256-128" + default: + return "" + } +} + +func dhName(identifier uint16) string { + switch identifier { + case dhMODP1024: + return "modp1024" + case dhMODP2048: + return "modp2048" + default: + return "" + } +} + +type NetworkEvidence struct { + LocalIPv4 string + LocalIPv6 string + DNS []string + PCSCF []string + DataplaneMode string +} + +type Session struct { + mu sync.Mutex + evidence vowifi.TunnelEvidence + network NetworkEvidence + child ChildSAHandle + relay *sessionRelay + transport datagramTransport + closed bool +} + +func (session *Session) Evidence() vowifi.TunnelEvidence { + session.mu.Lock() + defer session.mu.Unlock() + evidence := session.evidence + evidence.PCSCF = append([]string(nil), session.evidence.PCSCF...) + return evidence +} + +func (session *Session) Network() NetworkEvidence { + session.mu.Lock() + defer session.mu.Unlock() + network := session.network + network.DNS = append([]string(nil), session.network.DNS...) + network.PCSCF = append([]string(nil), session.network.PCSCF...) + return network +} + +func (session *Session) Failures() <-chan error { + session.mu.Lock() + defer session.mu.Unlock() + if notifier, ok := session.child.(DataplaneFailureNotifier); ok { + return notifier.Failures() + } + return nil +} + +func (session *Session) Close(ctx context.Context) error { + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return nil + } + session.closed = true + child := session.child + relay := session.relay + transport := session.transport + session.evidence.Established = false + session.child = nil + session.relay = nil + session.transport = nil + session.mu.Unlock() + var errs []error + if child != nil { + if err := child.Close(ctx); err != nil { + errs = append(errs, fmt.Errorf("remove CHILD_SA: %w", err)) + } + } + if relay != nil { + if err := relay.Close(); err != nil { + errs = append(errs, fmt.Errorf("close session relay: %w", err)) + } + } + if transport != nil { + if err := transport.Close(); err != nil { + errs = append(errs, fmt.Errorf("close IKE transport: %w", err)) + } + } + return errors.Join(errs...) +} + +var _ vowifi.TunnelProvider = (*Provider)(nil) +var _ vowifi.TunnelSession = (*Session)(nil) +var _ vowifi.RuntimeFailureNotifier = (*Session)(nil) diff --git a/internal/vowifi/ike/provider_name_test.go b/internal/vowifi/ike/provider_name_test.go new file mode 100644 index 0000000..42a5cbc --- /dev/null +++ b/internal/vowifi/ike/provider_name_test.go @@ -0,0 +1,44 @@ +package ike + +import ( + "net" + "testing" +) + +func TestTunnelNameIsStableShortAndUsesCompleteDeviceID(t *testing.T) { + t.Parallel() + first := tunnelName("quectel-0125-1-6") + if first != tunnelName("QUECTEL-0125-1-6") { + t.Fatalf("name is not stable across normalized device IDs: %q", first) + } + if len(first) != 15 { + t.Fatalf("interface name length = %d, want 15", len(first)) + } + if first == tunnelName("quectel-0125-1-7") { + t.Fatalf("distinct devices sharing a long prefix received %q", first) + } +} + +func TestPCSCFFilterKeepsOnlyAssignedAddressFamilies(t *testing.T) { + t.Parallel() + values := []net.IP{ + net.ParseIP("2001:db8::20"), + net.IPv4(10, 127, 192, 82), + } + ipv4 := pcscfForAssignedFamilies(values, true, false) + if len(ipv4) != 1 || ipv4[0].String() != "10.127.192.82" { + t.Fatalf("IPv4 P-CSCF list = %v", ipv4) + } + dual := pcscfForAssignedFamilies(values, true, true) + if len(dual) != 2 { + t.Fatalf("dual-stack P-CSCF list = %v", dual) + } + duplicate := pcscfForAssignedFamilies( + append(values, net.IPv4(10, 127, 192, 82)), + true, + true, + ) + if len(duplicate) != 2 { + t.Fatalf("duplicate P-CSCF was not removed: %v", duplicate) + } +} diff --git a/internal/vowifi/ike/provider_test.go b/internal/vowifi/ike/provider_test.go new file mode 100644 index 0000000..c2e3277 --- /dev/null +++ b/internal/vowifi/ike/provider_test.go @@ -0,0 +1,236 @@ +package ike + +import ( + "bytes" + "context" + "errors" + "io" + "net" + "testing" + "time" + + "vocat/internal/vowifi" +) + +var errFirstAuthObserved = errors.New("test: first IKE_AUTH observed") + +type constantReader struct{ value byte } + +func (reader constantReader) Read(destination []byte) (int, error) { + for index := range destination { + destination[index] = reader.value + } + return len(destination), nil +} + +type firstAuthCaptureTransport struct { + t *testing.T + calls int + suite negotiatedSuite + keys ikeKeys + spii [8]byte + spir [8]byte + nonceI []byte + nonceR []byte + floated bool +} + +func (transport *firstAuthCaptureTransport) LocalAddr() *net.UDPAddr { + return &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 500} +} + +func (transport *firstAuthCaptureTransport) RemoteAddr() *net.UDPAddr { + return &net.UDPAddr{IP: net.IPv4(198, 51, 100, 20), Port: 500} +} + +func (transport *firstAuthCaptureTransport) Float(context.Context) error { + transport.floated = true + return nil +} + +func (transport *firstAuthCaptureTransport) RoundTrip(_ context.Context, packet []byte) ([]byte, error) { + transport.calls++ + switch transport.calls { + case 1: + return transport.answerIKEInit(packet) + case 2: + return nil, transport.observeFirstAuth(packet) + default: + return nil, errors.New("test: unexpected exchange") + } +} + +func (transport *firstAuthCaptureTransport) answerIKEInit(packet []byte) ([]byte, error) { + header, body, err := parseIKEPacket(packet) + if err != nil { + return nil, err + } + payloads, err := parsePayloadChain(header.NextPayload, body) + if err != nil { + return nil, err + } + ke, err := onePayload(payloads, payloadKE) + if err != nil { + return nil, err + } + nonce, err := onePayload(payloads, payloadNonce) + if err != nil { + return nil, err + } + group := uint16(ke.Body[0])<<8 | uint16(ke.Body[1]) + if group != dhMODP1024 || len(ke.Body[4:]) != 128 { + transport.t.Fatalf("Vodafone init KE = group %d length %d", group, len(ke.Body[4:])) + } + serverDH, err := newDHExchange(group, constantReader{value: 0x77}) + if err != nil { + return nil, err + } + shared, err := serverDH.shared(ke.Body[4:]) + if err != nil { + return nil, err + } + transport.suite = legacyTestSuite() + transport.spii = header.InitiatorSPI + transport.spir = [8]byte{0x80, 1, 2, 3, 4, 5, 6, 7} + transport.nonceI = append([]byte(nil), nonce.Body...) + transport.nonceR = bytes.Repeat([]byte{0x88}, 32) + transport.keys, err = deriveIKEKeys( + transport.suite, + shared, + transport.nonceI, + transport.nonceR, + transport.spii, + transport.spir, + ) + if err != nil { + return nil, err + } + selectedSA, _ := marshalProposals([]proposal{{ + Number: 1, + Protocol: protocolIKE, + Transforms: []transform{ + {Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128}, + {Type: transformPRF, ID: prfHMACSHA1}, + {Type: transformIntegrity, ID: integrityHMACSHA1_96}, + {Type: transformDH, ID: dhMODP1024}, + }, + }}) + keBody := make([]byte, 4+len(serverDH.Public)) + keBody[1] = byte(group) + copy(keBody[4:], serverDH.Public) + first, responseBody, _ := marshalPayloadChain([]payload{ + {Type: payloadSA, Body: selectedSA}, + {Type: payloadKE, Body: keBody}, + {Type: payloadNonce, Body: transport.nonceR}, + }) + return ikeHeader{ + InitiatorSPI: transport.spii, + ResponderSPI: transport.spir, + NextPayload: first, + Exchange: exchangeIKEInit, + Flags: flagResponse, + }.marshal(responseBody), nil +} + +func (transport *firstAuthCaptureTransport) observeFirstAuth(packet []byte) error { + header, payloads, err := decryptPayloads( + packet, + transport.suite, + transport.keys.SKei, + transport.keys.SKai, + ) + if err != nil { + return err + } + if header.Exchange != exchangeIKEAuth || header.MessageID != 1 || header.Flags != flagInitiator { + transport.t.Fatalf("first IKE_AUTH header = %#v", header) + } + idr, err := onePayload(payloads, payloadIDr) + if err != nil { + transport.t.Fatal(err) + } + if idr.Body[0] != 2 || string(idr.Body[4:]) != "ims" { + transport.t.Fatalf("requested IDr = type %d value %q", idr.Body[0], idr.Body[4:]) + } + foundEAPOnly := false + for _, item := range payloadsOfType(payloads, payloadNotify) { + kind, data, err := parseNotify(item) + if err != nil { + transport.t.Fatal(err) + } + if kind == notifyEAPOnlyAuth && len(data) == 0 { + foundEAPOnly = true + } + } + if !foundEAPOnly { + transport.t.Fatal("first IKE_AUTH omitted EAP_ONLY_AUTHENTICATION") + } + for _, kind := range []uint8{payloadIDi, payloadSA, payloadTSi, payloadTSr, payloadCP} { + if _, err := onePayload(payloads, kind); err != nil { + transport.t.Fatalf("first IKE_AUTH payload %d: %v", kind, err) + } + } + return errFirstAuthObserved +} + +func (*firstAuthCaptureTransport) SendESP(context.Context, []byte) error { + return errors.New("test: unused") +} +func (*firstAuthCaptureTransport) ReceiveESP(context.Context, []byte) (int, error) { + return 0, errors.New("test: unused") +} +func (*firstAuthCaptureTransport) SendSessionPacket(context.Context, []byte, bool) error { + return errors.New("test: unused") +} +func (*firstAuthCaptureTransport) ReceiveSessionPacket(context.Context, []byte) (int, bool, error) { + return 0, false, errors.New("test: unused") +} +func (*firstAuthCaptureTransport) Close() error { return nil } + +type unusedInstaller struct{} + +func (unusedInstaller) Install(context.Context, ChildSAConfig) (ChildSAHandle, error) { + return nil, errors.New("test: installer must not run") +} + +func TestProviderVodafoneFirstAuthIsEAPOnlyAndRequestsIMSAPN(t *testing.T) { + capture := &firstAuthCaptureTransport{t: t} + provider, err := NewProvider(Config{ + Random: constantReader{value: 0x42}, + Timeout: time.Second, + Installer: unusedInstaller{}, + APN: "ims", + }) + if err != nil { + t.Fatal(err) + } + provider.transportFactory = func( + context.Context, + transportConfig, + vowifi.ProxyRoute, + string, + ) (datagramTransport, error) { + return capture, nil + } + aka := &testAKAProvider{} + _, err = provider.Start(context.Background(), vowifi.TunnelRequest{ + DeviceID: "ec20-1", + Identity: vowifi.SIMIdentity{ + ICCID: "8944100000000000000", + IMSI: "234150123456789", + HomeMCC: "234", + HomeMNC: "15", + }, + EPDG: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org", + AKA: aka, + }) + if !errors.Is(err, errFirstAuthObserved) { + t.Fatalf("Start() error = %v, want capture sentinel", err) + } + if capture.calls != 2 || capture.floated || aka.calls != 0 { + t.Fatalf("capture calls=%d floated=%v AKA calls=%d", capture.calls, capture.floated, aka.calls) + } +} + +var _ io.Reader = constantReader{} +var _ datagramTransport = (*firstAuthCaptureTransport)(nil) diff --git a/internal/vowifi/ike/relay.go b/internal/vowifi/ike/relay.go new file mode 100644 index 0000000..7691f04 --- /dev/null +++ b/internal/vowifi/ike/relay.go @@ -0,0 +1,209 @@ +package ike + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "time" +) + +type sessionRelay struct { + transport datagramTransport + suite negotiatedSuite + keys ikeKeys + spii [8]byte + spir [8]byte + natt bool + keepalive time.Duration + + ctx context.Context + cancel context.CancelFunc + done chan struct{} + esp chan []byte + + mu sync.Mutex + lastErr error +} + +func newSessionRelay( + transport datagramTransport, + suite negotiatedSuite, + keys ikeKeys, + initiatorSPI [8]byte, + responderSPI [8]byte, + natt bool, + keepalive time.Duration, +) *sessionRelay { + if keepalive <= 0 { + keepalive = 20 * time.Second + } + ctx, cancel := context.WithCancel(context.Background()) + relay := &sessionRelay{ + transport: transport, + suite: suite, + keys: keys, + spii: initiatorSPI, + spir: responderSPI, + natt: natt, + keepalive: keepalive, + ctx: ctx, + cancel: cancel, + done: make(chan struct{}), + esp: make(chan []byte, 64), + } + go relay.run() + return relay +} + +func (relay *sessionRelay) run() { + defer close(relay.done) + defer close(relay.esp) + buffer := make([]byte, 65535) + lastKeepalive := time.Now() + for { + if err := relay.ctx.Err(); err != nil { + return + } + n, isIKE, err := relay.transport.ReceiveSessionPacket(relay.ctx, buffer) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, net.ErrClosed) { + return + } + if timeout, ok := err.(net.Error); ok && timeout.Timeout() { + if relay.natt && time.Since(lastKeepalive) >= relay.keepalive { + if sendErr := relay.transport.SendSessionPacket(relay.ctx, []byte{0xff}, false); sendErr != nil { + relay.fail(sendErr) + return + } + lastKeepalive = time.Now() + } + continue + } + relay.fail(err) + return + } + packet := append([]byte(nil), buffer[:n]...) + if isIKE { + if err := relay.handleIKE(packet); err != nil { + relay.fail(err) + return + } + continue + } + if len(packet) == 1 && packet[0] == 0xff { + // Peer NAT keepalive. + continue + } + if len(packet) < 8 { + // Unauthenticated network input must not tear down the session. + continue + } + select { + case relay.esp <- packet: + default: + // Keep the sole socket reader available for IKE/DPD if the + // data-plane consumer falls behind. + case <-relay.ctx.Done(): + return + } + } +} + +func (relay *sessionRelay) handleIKE(packet []byte) error { + header, _, err := parseIKEPacket(packet) + if err != nil { + return err + } + if header.InitiatorSPI != relay.spii || header.ResponderSPI != relay.spir { + return errors.New("ike: session packet has mismatched SPIs") + } + if header.Flags&flagResponse != 0 { + return nil + } + if header.Exchange != exchangeInformational { + return fmt.Errorf("ike: unsupported responder-initiated exchange %d", header.Exchange) + } + decryptedHeader, payloads, err := decryptPayloads(packet, relay.suite, relay.keys.SKer, relay.keys.SKar) + if err != nil { + return err + } + if len(payloads) != 0 { + return errors.New("ike: responder INFORMATIONAL request is not an empty DPD probe") + } + response, err := encryptPayloads(ikeHeader{ + InitiatorSPI: relay.spii, + ResponderSPI: relay.spir, + Exchange: exchangeInformational, + Flags: flagInitiator | flagResponse, + MessageID: decryptedHeader.MessageID, + }, nil, relay.suite, relay.keys.SKei, relay.keys.SKai, nil) + if err != nil { + return err + } + return relay.transport.SendSessionPacket(relay.ctx, response, true) +} + +func (relay *sessionRelay) fail(err error) { + relay.mu.Lock() + if relay.lastErr == nil { + relay.lastErr = err + } + relay.mu.Unlock() + relay.cancel() +} + +func (relay *sessionRelay) SendESP(ctx context.Context, packet []byte) error { + if ctx == nil { + ctx = context.Background() + } + select { + case <-relay.done: + return relay.terminalError() + default: + } + return relay.transport.SendSessionPacket(ctx, packet, false) +} + +func (relay *sessionRelay) ReceiveESP(ctx context.Context, buffer []byte) (int, error) { + if ctx == nil { + ctx = context.Background() + } + select { + case <-ctx.Done(): + return 0, ctx.Err() + case packet, ok := <-relay.esp: + if !ok { + return 0, relay.terminalError() + } + if len(packet) > len(buffer) { + return 0, errors.New("ike: ESP receive buffer is too small") + } + copy(buffer, packet) + return len(packet), nil + } +} + +func (relay *sessionRelay) terminalError() error { + relay.mu.Lock() + defer relay.mu.Unlock() + if relay.lastErr != nil { + return relay.lastErr + } + return net.ErrClosed +} + +func (relay *sessionRelay) Close() error { + relay.cancel() + <-relay.done + return relay.terminalErrorIfFailure() +} + +func (relay *sessionRelay) terminalErrorIfFailure() error { + relay.mu.Lock() + defer relay.mu.Unlock() + return relay.lastErr +} + +var _ NATTPacketRelay = (*sessionRelay)(nil) diff --git a/internal/vowifi/ike/relay_test.go b/internal/vowifi/ike/relay_test.go new file mode 100644 index 0000000..44c54a0 --- /dev/null +++ b/internal/vowifi/ike/relay_test.go @@ -0,0 +1,180 @@ +package ike + +import ( + "bytes" + "context" + "net" + "sync" + "sync/atomic" + "testing" + "time" +) + +type fakeSessionPacket struct { + data []byte + ike bool + err error +} + +type fakeSentPacket struct { + data []byte + ike bool +} + +type fakeSessionTransport struct { + incoming chan fakeSessionPacket + sent chan fakeSentPacket + closed chan struct{} + once sync.Once + readers atomic.Int32 + maxReads atomic.Int32 +} + +func newFakeSessionTransport() *fakeSessionTransport { + return &fakeSessionTransport{ + incoming: make(chan fakeSessionPacket, 16), + sent: make(chan fakeSentPacket, 16), + closed: make(chan struct{}), + } +} + +func (transport *fakeSessionTransport) LocalAddr() *net.UDPAddr { + return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 4500} +} +func (transport *fakeSessionTransport) RemoteAddr() *net.UDPAddr { + return &net.UDPAddr{IP: net.IPv4(127, 0, 0, 2), Port: 4500} +} +func (*fakeSessionTransport) Float(context.Context) error { return nil } +func (*fakeSessionTransport) RoundTrip(context.Context, []byte) ([]byte, error) { + return nil, context.DeadlineExceeded +} +func (transport *fakeSessionTransport) SendESP(ctx context.Context, packet []byte) error { + return transport.SendSessionPacket(ctx, packet, false) +} +func (transport *fakeSessionTransport) ReceiveESP(ctx context.Context, buffer []byte) (int, error) { + n, _, err := transport.ReceiveSessionPacket(ctx, buffer) + return n, err +} +func (transport *fakeSessionTransport) SendSessionPacket( + ctx context.Context, + packet []byte, + ike bool, +) error { + select { + case transport.sent <- fakeSentPacket{data: append([]byte(nil), packet...), ike: ike}: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-transport.closed: + return net.ErrClosed + } +} +func (transport *fakeSessionTransport) ReceiveSessionPacket( + ctx context.Context, + buffer []byte, +) (int, bool, error) { + active := transport.readers.Add(1) + for { + current := transport.maxReads.Load() + if active <= current || transport.maxReads.CompareAndSwap(current, active) { + break + } + } + defer transport.readers.Add(-1) + select { + case packet := <-transport.incoming: + if packet.err != nil { + return 0, false, packet.err + } + copy(buffer, packet.data) + return len(packet.data), packet.ike, nil + case <-time.After(5 * time.Millisecond): + return 0, false, deadlineError{} + case <-ctx.Done(): + return 0, false, ctx.Err() + case <-transport.closed: + return 0, false, net.ErrClosed + } +} +func (transport *fakeSessionTransport) Close() error { + transport.once.Do(func() { close(transport.closed) }) + return nil +} + +func TestSessionRelayDemuxesESPAndAnswersEncryptedDPD(t *testing.T) { + transport := newFakeSessionTransport() + suite := legacyTestSuite() + keys := ikeKeys{ + SKai: bytes.Repeat([]byte{0x11}, 20), + SKar: bytes.Repeat([]byte{0x12}, 20), + SKei: bytes.Repeat([]byte{0x13}, 16), + SKer: bytes.Repeat([]byte{0x14}, 16), + } + spii := [8]byte{1} + spir := [8]byte{2} + relay := newSessionRelay(transport, suite, keys, spii, spir, true, time.Hour) + defer relay.Close() + + esp := []byte{0, 0, 0, 9, 0, 0, 0, 1, 0xaa} + transport.incoming <- fakeSessionPacket{data: esp, ike: false} + buffer := make([]byte, 64) + n, err := relay.ReceiveESP(context.Background(), buffer) + if err != nil { + t.Fatalf("ReceiveESP() error = %v", err) + } + if !bytes.Equal(buffer[:n], esp) { + t.Fatalf("demuxed ESP = %x, want %x", buffer[:n], esp) + } + + dpd, err := encryptPayloads(ikeHeader{ + InitiatorSPI: spii, + ResponderSPI: spir, + Exchange: exchangeInformational, + MessageID: 8, + }, nil, suite, keys.SKer, keys.SKar, bytes.NewReader(bytes.Repeat([]byte{0x44}, 64))) + if err != nil { + t.Fatal(err) + } + transport.incoming <- fakeSessionPacket{data: dpd, ike: true} + select { + case response := <-transport.sent: + if !response.ike { + t.Fatal("DPD response was sent as ESP") + } + header, payloads, err := decryptPayloads(response.data, suite, keys.SKei, keys.SKai) + if err != nil { + t.Fatalf("decrypt DPD response: %v", err) + } + if header.Exchange != exchangeInformational || header.MessageID != 8 || + header.Flags != flagInitiator|flagResponse || len(payloads) != 0 { + t.Fatalf("DPD response header/payloads = %#v %#v", header, payloads) + } + case <-time.After(time.Second): + t.Fatal("relay did not answer DPD") + } + if maximum := transport.maxReads.Load(); maximum != 1 { + t.Fatalf("concurrent socket readers = %d, want exactly one", maximum) + } +} + +func TestSessionRelaySendsNATKeepalive(t *testing.T) { + transport := newFakeSessionTransport() + relay := newSessionRelay( + transport, + legacyTestSuite(), + ikeKeys{}, + [8]byte{1}, + [8]byte{2}, + true, + 10*time.Millisecond, + ) + defer relay.Close() + select { + case packet := <-transport.sent: + if packet.ike || !bytes.Equal(packet.data, []byte{0xff}) { + t.Fatalf("keepalive = ike:%v data:%x", packet.ike, packet.data) + } + case <-time.After(500 * time.Millisecond): + t.Fatal("relay did not send a NAT-T keepalive") + } +} diff --git a/internal/vowifi/ike/transport.go b/internal/vowifi/ike/transport.go new file mode 100644 index 0000000..425936b --- /dev/null +++ b/internal/vowifi/ike/transport.go @@ -0,0 +1,934 @@ +package ike + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "strconv" + "strings" + "sync" + "time" + + "vocat/internal/vowifi" +) + +type datagramTransport interface { + LocalAddr() *net.UDPAddr + RemoteAddr() *net.UDPAddr + Float(context.Context) error + RoundTrip(context.Context, []byte) ([]byte, error) + SendESP(context.Context, []byte) error + ReceiveESP(context.Context, []byte) (int, error) + SendSessionPacket(context.Context, []byte, bool) error + ReceiveSessionPacket(context.Context, []byte) (int, bool, error) + Close() error +} + +type transportConfig struct { + Resolver *net.Resolver + Dialer *net.Dialer + Timeout time.Duration +} + +func newDatagramTransport( + ctx context.Context, + config transportConfig, + route vowifi.ProxyRoute, + host string, +) (datagramTransport, error) { + if config.Resolver == nil { + config.Resolver = net.DefaultResolver + } + if config.Dialer == nil { + config.Dialer = &net.Dialer{} + } + if config.Timeout <= 0 { + config.Timeout = 12 * time.Second + } + addresses, err := resolveEPDG(ctx, config.Resolver, host) + if err != nil { + return nil, fmt.Errorf("ike: resolve ePDG: %w", err) + } + var remoteIPs []net.IP + for _, address := range addresses { + candidate := address.IP.To4() + if candidate == nil { + candidate = address.IP.To16() + } + if candidate == nil { + continue + } + duplicate := false + for _, existing := range remoteIPs { + if existing.Equal(candidate) { + duplicate = true + break + } + } + if !duplicate { + remoteIPs = append(remoteIPs, append(net.IP(nil), candidate...)) + } + } + if len(remoteIPs) == 0 { + return nil, errors.New("ike: ePDG did not resolve to an IP address") + } + remotes := make([]*net.UDPAddr, 0, len(remoteIPs)) + for _, remoteIP := range remoteIPs { + remotes = append(remotes, &net.UDPAddr{IP: remoteIP, Port: 500}) + } + switch route.Mode { + case "", vowifi.ProxyModeDirect: + return newDirectUDP(ctx, config, remotes[0]) + case vowifi.ProxyModeSOCKS5: + return newSOCKS5UDP(ctx, config, route, remotes) + default: + return nil, fmt.Errorf("ike: unsupported proxy mode %q", route.Mode) + } +} + +func roundTripDatagram( + ctx context.Context, + timeout time.Duration, + write func([]byte) error, + read func([]byte, time.Time) (int, error), + packet []byte, +) ([]byte, error) { + if ctx == nil { + ctx = context.Background() + } + deadline := time.Now().Add(timeout) + if callerDeadline, ok := ctx.Deadline(); ok && callerDeadline.Before(deadline) { + deadline = callerDeadline + } + retransmit := []time.Duration{500 * time.Millisecond, time.Second, 2 * time.Second, 4 * time.Second} + buffer := make([]byte, 65535) + var lastErr error + for _, interval := range retransmit { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := write(packet); err != nil { + return nil, err + } + attemptDeadline := time.Now().Add(interval) + if deadline.Before(attemptDeadline) { + attemptDeadline = deadline + } + for time.Now().Before(attemptDeadline) { + if err := ctx.Err(); err != nil { + return nil, err + } + n, err := read(buffer, attemptDeadline) + if err == nil { + return append([]byte(nil), buffer[:n]...), nil + } + if timeoutError, ok := err.(net.Error); ok && timeoutError.Timeout() { + lastErr = err + break + } + return nil, err + } + if !time.Now().Before(deadline) { + break + } + } + if lastErr == nil { + lastErr = context.DeadlineExceeded + } + return nil, fmt.Errorf("ike: UDP exchange timed out: %w", lastErr) +} + +type directUDP struct { + mu sync.Mutex + readMu sync.Mutex + writeMu sync.Mutex + config transportConfig + conn *net.UDPConn + remote *net.UDPAddr + floated bool +} + +func newDirectUDP(ctx context.Context, config transportConfig, remote *net.UDPAddr) (*directUDP, error) { + transport := &directUDP{config: config, remote: cloneUDPAddr(remote)} + if err := transport.dial(ctx, false); err != nil { + return nil, err + } + return transport, nil +} + +func (transport *directUDP) dial(ctx context.Context, bind4500 bool) error { + dialer := *transport.config.Dialer + if bind4500 { + localIP := net.IP(nil) + if transport.conn != nil { + if current, ok := transport.conn.LocalAddr().(*net.UDPAddr); ok { + localIP = append(net.IP(nil), current.IP...) + } + } + dialer.LocalAddr = &net.UDPAddr{IP: localIP, Port: 4500} + } + connection, err := dialer.DialContext(ctx, "udp", transport.remote.String()) + if err != nil && bind4500 { + dialer.LocalAddr = nil + connection, err = dialer.DialContext(ctx, "udp", transport.remote.String()) + } + if err != nil { + return fmt.Errorf("ike: dial ePDG UDP: %w", err) + } + udp, ok := connection.(*net.UDPConn) + if !ok { + _ = connection.Close() + return errors.New("ike: UDP dialer returned a non-UDP connection") + } + old := transport.conn + transport.conn = udp + if old != nil { + _ = old.Close() + } + return nil +} + +func (transport *directUDP) LocalAddr() *net.UDPAddr { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.conn == nil { + return nil + } + address, _ := transport.conn.LocalAddr().(*net.UDPAddr) + return cloneUDPAddr(address) +} + +func (transport *directUDP) RemoteAddr() *net.UDPAddr { + transport.mu.Lock() + defer transport.mu.Unlock() + return cloneUDPAddr(transport.remote) +} + +func (transport *directUDP) Float(ctx context.Context) error { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.floated { + return nil + } + transport.remote.Port = 4500 + if err := transport.dial(ctx, true); err != nil { + return err + } + transport.floated = true + return nil +} + +func (transport *directUDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.conn == nil { + return nil, errors.New("ike: UDP transport is closed") + } + requestHeader, _, err := parseIKEPacket(packet) + if err != nil { + return nil, fmt.Errorf("ike: invalid outbound packet: %w", err) + } + wirePacket := packet + if transport.floated { + wirePacket = append([]byte{0, 0, 0, 0}, packet...) + } + write := func(value []byte) error { + if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil { + return err + } + _, err := transport.conn.Write(value) + return err + } + read := func(buffer []byte, attemptDeadline time.Time) (int, error) { + for { + if err := transport.conn.SetReadDeadline(attemptDeadline); err != nil { + return 0, err + } + n, err := transport.conn.Read(buffer) + if err != nil { + return 0, err + } + if transport.floated { + // IKE and ESP legitimately share UDP/4500. An ESP packet can + // arrive immediately before the IKE response that completes + // CHILD_SA setup; discard it here and keep the same absolute + // attempt deadline while waiting for marked IKE. + if !hasNonESPMarker(buffer[:n]) { + continue + } + copy(buffer, buffer[4:n]) + n -= 4 + } + if !ikeResponseMatchesRequest(buffer[:n], requestHeader) { + continue + } + return n, nil + } + } + return roundTripDatagram(ctx, transport.config.Timeout, write, read, wirePacket) +} + +func (transport *directUDP) SendESP(ctx context.Context, packet []byte) error { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.conn == nil || !transport.floated { + return errors.New("ike: ESP relay requires an active UDP/4500 transport") + } + if len(packet) < 8 { + return errors.New("ike: ESP packet is too short") + } + if err := transport.conn.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil { + return err + } + _, err := transport.conn.Write(packet) + return err +} + +func (transport *directUDP) ReceiveESP(ctx context.Context, buffer []byte) (int, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.conn == nil || !transport.floated { + return 0, errors.New("ike: ESP relay requires an active UDP/4500 transport") + } + if err := transport.conn.SetReadDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil { + return 0, err + } + n, err := transport.conn.Read(buffer) + if err != nil { + return 0, err + } + if n >= 4 && buffer[0] == 0 && buffer[1] == 0 && buffer[2] == 0 && buffer[3] == 0 { + return 0, errors.New("ike: received an IKE packet on the ESP relay") + } + return n, nil +} + +func (transport *directUDP) SendSessionPacket(ctx context.Context, packet []byte, ike bool) error { + transport.mu.Lock() + connection := transport.conn + floated := transport.floated + transport.mu.Unlock() + if connection == nil { + return errors.New("ike: UDP transport is closed") + } + wire := packet + if floated { + if ike { + wire = append([]byte{0, 0, 0, 0}, packet...) + } + } else if !ike { + return errors.New("ike: ESP is not UDP encapsulated on an un-floated transport") + } + transport.writeMu.Lock() + defer transport.writeMu.Unlock() + if err := connection.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil { + return err + } + _, err := connection.Write(wire) + return err +} + +func (transport *directUDP) ReceiveSessionPacket(ctx context.Context, buffer []byte) (int, bool, error) { + transport.mu.Lock() + connection := transport.conn + floated := transport.floated + transport.mu.Unlock() + if connection == nil { + return 0, false, errors.New("ike: UDP transport is closed") + } + transport.readMu.Lock() + defer transport.readMu.Unlock() + if err := connection.SetReadDeadline(deadlineFor(ctx, time.Second)); err != nil { + return 0, false, err + } + n, err := connection.Read(buffer) + if err != nil { + return 0, false, err + } + if !floated { + return n, true, nil + } + if n >= 4 && buffer[0] == 0 && buffer[1] == 0 && buffer[2] == 0 && buffer[3] == 0 { + copy(buffer, buffer[4:n]) + return n - 4, true, nil + } + return n, false, nil +} + +func (transport *directUDP) Close() error { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.conn == nil { + return nil + } + err := transport.conn.Close() + transport.conn = nil + return err +} + +type socks5UDP struct { + mu sync.Mutex + readMu sync.Mutex + writeMu sync.Mutex + config transportConfig + control net.Conn + udp *net.UDPConn + relay *net.UDPAddr + remote *net.UDPAddr + remotes []*net.UDPAddr + floated bool +} + +func newSOCKS5UDP( + ctx context.Context, + config transportConfig, + route vowifi.ProxyRoute, + remotes []*net.UDPAddr, +) (*socks5UDP, error) { + if len(remotes) == 0 || remotes[0] == nil { + return nil, errors.New("ike: SOCKS5 transport requires an ePDG destination") + } + proxyAddress := strings.TrimSpace(route.Address) + if _, _, err := net.SplitHostPort(proxyAddress); err != nil { + return nil, fmt.Errorf("ike: invalid SOCKS5 proxy address: %w", err) + } + control, err := config.Dialer.DialContext(ctx, "tcp", proxyAddress) + if err != nil { + return nil, fmt.Errorf("ike: connect SOCKS5 proxy %s: %w", proxyAddress, err) + } + fail := func(cause error) (*socks5UDP, error) { + _ = control.Close() + return nil, cause + } + if err := control.SetDeadline(deadlineFor(ctx, config.Timeout)); err != nil { + return fail(err) + } + methods := []byte{0} + if route.Username != "" { + methods = append(methods, 2) + } + greeting := append([]byte{5, byte(len(methods))}, methods...) + if _, err := control.Write(greeting); err != nil { + return fail(fmt.Errorf("ike: SOCKS5 greeting: %w", err)) + } + var selection [2]byte + if _, err := io.ReadFull(control, selection[:]); err != nil { + return fail(fmt.Errorf("ike: SOCKS5 method selection: %w", err)) + } + if selection[0] != 5 { + return fail(errors.New("ike: SOCKS5 proxy returned an invalid version")) + } + switch selection[1] { + case 0: + case 2: + if err := socksUserPassword(control, route.Username, route.Password); err != nil { + return fail(err) + } + default: + return fail(fmt.Errorf("ike: SOCKS5 proxy selected unsupported authentication method %d", selection[1])) + } + if _, err := control.Write([]byte{5, 3, 0, 1, 0, 0, 0, 0, 0, 0}); err != nil { + return fail(fmt.Errorf("ike: SOCKS5 UDP ASSOCIATE request: %w", err)) + } + relay, err := readSOCKS5Reply(ctx, control, config.Resolver) + if err != nil { + return fail(err) + } + if relay.IP == nil || relay.IP.IsUnspecified() { + if peer, ok := control.RemoteAddr().(*net.TCPAddr); ok { + relay.IP = append(net.IP(nil), peer.IP...) + } + } + udpConnection, err := net.DialUDP("udp", nil, relay) + if err != nil { + return fail(fmt.Errorf("ike: dial SOCKS5 UDP relay: %w", err)) + } + _ = control.SetDeadline(time.Time{}) + return &socks5UDP{ + config: config, + control: control, + udp: udpConnection, + relay: relay, + remote: cloneUDPAddr(remotes[0]), + remotes: cloneUDPAddrs(remotes), + }, nil +} + +func socksUserPassword(connection net.Conn, username, password string) error { + if len(username) > 255 || len(password) > 255 { + return errors.New("ike: SOCKS5 username or password exceeds 255 bytes") + } + request := []byte{1, byte(len(username))} + request = append(request, username...) + request = append(request, byte(len(password))) + request = append(request, password...) + if _, err := connection.Write(request); err != nil { + return fmt.Errorf("ike: SOCKS5 credential exchange: %w", err) + } + var response [2]byte + if _, err := io.ReadFull(connection, response[:]); err != nil { + return fmt.Errorf("ike: SOCKS5 credential response: %w", err) + } + if response[0] != 1 || response[1] != 0 { + return errors.New("ike: SOCKS5 authentication failed") + } + return nil +} + +func readSOCKS5Reply(ctx context.Context, connection net.Conn, resolver *net.Resolver) (*net.UDPAddr, error) { + var header [4]byte + if _, err := io.ReadFull(connection, header[:]); err != nil { + return nil, fmt.Errorf("ike: SOCKS5 UDP ASSOCIATE response: %w", err) + } + if header[0] != 5 || header[1] != 0 || header[2] != 0 { + return nil, fmt.Errorf("ike: SOCKS5 UDP ASSOCIATE rejected with code %d", header[1]) + } + ip, name, err := readSOCKSAddress(connection, header[3]) + if err != nil { + return nil, err + } + var encodedPort [2]byte + if _, err := io.ReadFull(connection, encodedPort[:]); err != nil { + return nil, fmt.Errorf("ike: SOCKS5 relay port: %w", err) + } + if ip == nil && name != "" { + if resolver == nil { + resolver = net.DefaultResolver + } + addresses, err := resolver.LookupIPAddr(ctx, name) + if err != nil || len(addresses) == 0 { + return nil, fmt.Errorf("ike: resolve SOCKS5 UDP relay domain %q: %w", name, err) + } + ip = addresses[0].IP + } + return &net.UDPAddr{IP: ip, Port: int(binary.BigEndian.Uint16(encodedPort[:]))}, nil +} + +func readSOCKSAddress(reader io.Reader, kind byte) (net.IP, string, error) { + switch kind { + case 1: + ip := make(net.IP, net.IPv4len) + if _, err := io.ReadFull(reader, ip); err != nil { + return nil, "", err + } + return ip, "", nil + case 4: + ip := make(net.IP, net.IPv6len) + if _, err := io.ReadFull(reader, ip); err != nil { + return nil, "", err + } + return ip, "", nil + case 3: + var length [1]byte + if _, err := io.ReadFull(reader, length[:]); err != nil { + return nil, "", err + } + name := make([]byte, int(length[0])) + if _, err := io.ReadFull(reader, name); err != nil { + return nil, "", err + } + return nil, string(name), nil + default: + return nil, "", fmt.Errorf("ike: unsupported SOCKS5 address type %d", kind) + } +} + +func (transport *socks5UDP) LocalAddr() *net.UDPAddr { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.udp == nil { + return nil + } + address, _ := transport.udp.LocalAddr().(*net.UDPAddr) + return cloneUDPAddr(address) +} + +func (transport *socks5UDP) RemoteAddr() *net.UDPAddr { + transport.mu.Lock() + defer transport.mu.Unlock() + return cloneUDPAddr(transport.remote) +} + +func (transport *socks5UDP) Float(_ context.Context) error { + transport.mu.Lock() + defer transport.mu.Unlock() + transport.remote.Port = 4500 + transport.floated = true + return nil +} + +func (transport *socks5UDP) RoundTrip(ctx context.Context, packet []byte) ([]byte, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.udp == nil { + return nil, errors.New("ike: SOCKS5 UDP transport is closed") + } + requestHeader, _, err := parseIKEPacket(packet) + if err != nil { + return nil, fmt.Errorf("ike: invalid outbound packet: %w", err) + } + // Carrier ePDG hostnames commonly return several gateways. A SOCKS5 + // egress can reach a different subset than the local host, so an initial + // timeout on one address must not make the entire hostname unavailable. + // Once a gateway answers, keep it pinned for the lifetime of the IKE SA. + if !transport.floated && requestHeader.Exchange == exchangeIKEInit && requestHeader.MessageID == 0 && len(transport.remotes) > 1 { + var lastErr error + for _, candidate := range transport.remotes { + transport.remote = cloneUDPAddr(candidate) + response, attemptErr := transport.roundTripLocked(ctx, packet, requestHeader) + if attemptErr == nil { + return response, nil + } + lastErr = attemptErr + if ctx.Err() != nil || !isNetworkTimeout(attemptErr) { + return nil, attemptErr + } + } + return nil, fmt.Errorf("ike: all %d resolved ePDG addresses timed out: %w", len(transport.remotes), lastErr) + } + return transport.roundTripLocked(ctx, packet, requestHeader) +} + +func (transport *socks5UDP) roundTripLocked(ctx context.Context, packet []byte, requestHeader ikeHeader) ([]byte, error) { + wireIKE := packet + if transport.floated { + wireIKE = append([]byte{0, 0, 0, 0}, packet...) + } + datagram, err := marshalSOCKS5Datagram(transport.remote, wireIKE) + if err != nil { + return nil, err + } + write := func(value []byte) error { + if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil { + return err + } + _, err := transport.udp.Write(value) + return err + } + read := func(buffer []byte, attemptDeadline time.Time) (int, error) { + for { + payload, err := readExpectedSOCKS5Datagram( + transport.udp, + transport.remote, + buffer, + attemptDeadline, + ) + if err != nil { + return 0, err + } + if transport.floated { + // The relay can deliver ESP before the marked IKE response on + // the same UDP/4500 association. Do not accept it as IKE, and + // do not abort the exchange; keep waiting within the original + // deadline. + if !hasNonESPMarker(payload) { + continue + } + payload = payload[4:] + } + if !ikeResponseMatchesRequest(payload, requestHeader) { + continue + } + copy(buffer, payload) + return len(payload), nil + } + } + return roundTripDatagram(ctx, transport.config.Timeout, write, read, datagram) +} + +func isNetworkTimeout(err error) bool { + var networkError net.Error + return errors.As(err, &networkError) && networkError.Timeout() +} + +func (transport *socks5UDP) SendESP(ctx context.Context, packet []byte) error { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.udp == nil || !transport.floated { + return errors.New("ike: SOCKS5 ESP relay requires an active UDP/4500 association") + } + if len(packet) < 8 { + return errors.New("ike: ESP packet is too short") + } + datagram, err := marshalSOCKS5Datagram(transport.remote, packet) + if err != nil { + return err + } + if err := transport.udp.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil { + return err + } + _, err = transport.udp.Write(datagram) + return err +} + +func (transport *socks5UDP) ReceiveESP(ctx context.Context, buffer []byte) (int, error) { + transport.mu.Lock() + defer transport.mu.Unlock() + if transport.udp == nil || !transport.floated { + return 0, errors.New("ike: SOCKS5 ESP relay requires an active UDP/4500 association") + } + wire := make([]byte, len(buffer)+32) + payload, err := readExpectedSOCKS5Datagram( + transport.udp, + transport.remote, + wire, + deadlineFor(ctx, transport.config.Timeout), + ) + if err != nil { + return 0, err + } + if len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 && payload[2] == 0 && payload[3] == 0 { + return 0, errors.New("ike: received an IKE packet on the SOCKS5 ESP relay") + } + if len(payload) > len(buffer) { + return 0, io.ErrShortBuffer + } + copy(buffer, payload) + return len(payload), nil +} + +func (transport *socks5UDP) SendSessionPacket(ctx context.Context, packet []byte, ike bool) error { + transport.mu.Lock() + connection := transport.udp + floated := transport.floated + remote := cloneUDPAddr(transport.remote) + transport.mu.Unlock() + if connection == nil || !floated { + return errors.New("ike: SOCKS5 session transport is not on UDP/4500") + } + wire := packet + if ike { + wire = append([]byte{0, 0, 0, 0}, packet...) + } + datagram, err := marshalSOCKS5Datagram(remote, wire) + if err != nil { + return err + } + transport.writeMu.Lock() + defer transport.writeMu.Unlock() + if err := connection.SetWriteDeadline(deadlineFor(ctx, transport.config.Timeout)); err != nil { + return err + } + _, err = connection.Write(datagram) + return err +} + +func (transport *socks5UDP) ReceiveSessionPacket(ctx context.Context, buffer []byte) (int, bool, error) { + transport.mu.Lock() + connection := transport.udp + floated := transport.floated + remote := cloneUDPAddr(transport.remote) + transport.mu.Unlock() + if connection == nil || !floated { + return 0, false, errors.New("ike: SOCKS5 session transport is not on UDP/4500") + } + wire := make([]byte, len(buffer)+32) + transport.readMu.Lock() + defer transport.readMu.Unlock() + payload, err := readExpectedSOCKS5Datagram( + connection, + remote, + wire, + deadlineFor(ctx, time.Second), + ) + if err != nil { + return 0, false, err + } + isIKE := len(payload) >= 4 && payload[0] == 0 && payload[1] == 0 && payload[2] == 0 && payload[3] == 0 + if isIKE { + payload = payload[4:] + } + if len(payload) > len(buffer) { + return 0, false, io.ErrShortBuffer + } + copy(buffer, payload) + return len(payload), isIKE, nil +} + +func (transport *socks5UDP) Close() error { + transport.mu.Lock() + defer transport.mu.Unlock() + var errs []error + if transport.udp != nil { + if err := transport.udp.Close(); err != nil { + errs = append(errs, err) + } + transport.udp = nil + } + if transport.control != nil { + if err := transport.control.Close(); err != nil { + errs = append(errs, err) + } + transport.control = nil + } + return errors.Join(errs...) +} + +// readExpectedSOCKS5Datagram discards valid datagrams attributed to a +// different destination until the caller's deadline. A retransmitted +// IKE_SA_INIT response from UDP/500 can legitimately remain queued after the +// transport floats to UDP/4500; it must not be accepted as the current +// exchange, but it is not a reason to abort the authenticated session either. +func readExpectedSOCKS5Datagram( + connection *net.UDPConn, + remote *net.UDPAddr, + wire []byte, + deadline time.Time, +) ([]byte, error) { + if connection == nil || remote == nil { + return nil, errors.New("ike: SOCKS5 UDP transport is closed") + } + for { + if err := connection.SetReadDeadline(deadline); err != nil { + return nil, err + } + n, err := connection.Read(wire) + if err != nil { + return nil, err + } + payload, source, err := parseSOCKS5Datagram(wire[:n]) + if err != nil { + return nil, err + } + if source.IP.Equal(remote.IP) && source.Port == remote.Port { + return payload, nil + } + } +} + +func hasNonESPMarker(packet []byte) bool { + return len(packet) >= 4 && + packet[0] == 0 && + packet[1] == 0 && + packet[2] == 0 && + packet[3] == 0 +} + +func ikeResponseMatchesRequest( + packet []byte, + request ikeHeader, +) bool { + response, _, err := parseIKEPacket(packet) + if err != nil { + return false + } + if response.InitiatorSPI != request.InitiatorSPI || + response.Exchange != request.Exchange || + response.MessageID != request.MessageID || + response.Flags&flagResponse == 0 || + response.Flags&flagInitiator != 0 { + return false + } + var zeroSPI [8]byte + if request.ResponderSPI == zeroSPI { + return response.ResponderSPI != zeroSPI + } + return response.ResponderSPI == request.ResponderSPI +} + +func marshalSOCKS5Datagram(remote *net.UDPAddr, payload []byte) ([]byte, error) { + if remote == nil || remote.IP == nil || remote.Port < 1 || remote.Port > 65535 { + return nil, errors.New("ike: invalid SOCKS5 UDP destination") + } + result := []byte{0, 0, 0} + if ip4 := remote.IP.To4(); ip4 != nil { + result = append(result, 1) + result = append(result, ip4...) + } else if ip16 := remote.IP.To16(); ip16 != nil { + result = append(result, 4) + result = append(result, ip16...) + } else { + return nil, errors.New("ike: SOCKS5 UDP destination is not an IP address") + } + var port [2]byte + binary.BigEndian.PutUint16(port[:], uint16(remote.Port)) + result = append(result, port[:]...) + result = append(result, payload...) + return result, nil +} + +func parseSOCKS5Datagram(encoded []byte) ([]byte, *net.UDPAddr, error) { + if len(encoded) < 4 || encoded[0] != 0 || encoded[1] != 0 { + return nil, nil, errors.New("ike: malformed SOCKS5 UDP datagram") + } + if encoded[2] != 0 { + return nil, nil, errors.New("ike: fragmented SOCKS5 UDP datagrams are unsupported") + } + offset := 4 + var ip net.IP + switch encoded[3] { + case 1: + if offset+4 > len(encoded) { + return nil, nil, errors.New("ike: truncated SOCKS5 IPv4 address") + } + ip = append(net.IP(nil), encoded[offset:offset+4]...) + offset += 4 + case 4: + if offset+16 > len(encoded) { + return nil, nil, errors.New("ike: truncated SOCKS5 IPv6 address") + } + ip = append(net.IP(nil), encoded[offset:offset+16]...) + offset += 16 + case 3: + if offset >= len(encoded) { + return nil, nil, errors.New("ike: truncated SOCKS5 domain length") + } + length := int(encoded[offset]) + offset++ + if offset+length > len(encoded) { + return nil, nil, errors.New("ike: truncated SOCKS5 domain") + } + addresses, err := net.LookupIP(string(encoded[offset : offset+length])) + if err != nil || len(addresses) == 0 { + return nil, nil, errors.New("ike: cannot resolve SOCKS5 UDP response domain") + } + ip = addresses[0] + offset += length + default: + return nil, nil, errors.New("ike: unsupported SOCKS5 UDP address type") + } + if offset+2 > len(encoded) { + return nil, nil, errors.New("ike: truncated SOCKS5 UDP port") + } + port := int(binary.BigEndian.Uint16(encoded[offset : offset+2])) + offset += 2 + return append([]byte(nil), encoded[offset:]...), &net.UDPAddr{IP: ip, Port: port}, nil +} + +func deadlineFor(ctx context.Context, maximum time.Duration) time.Time { + deadline := time.Now().Add(maximum) + if ctx != nil { + if caller, ok := ctx.Deadline(); ok && caller.Before(deadline) { + return caller + } + } + return deadline +} + +func cloneUDPAddr(address *net.UDPAddr) *net.UDPAddr { + if address == nil { + return nil + } + return &net.UDPAddr{IP: append(net.IP(nil), address.IP...), Port: address.Port, Zone: address.Zone} +} + +func cloneUDPAddrs(addresses []*net.UDPAddr) []*net.UDPAddr { + result := make([]*net.UDPAddr, 0, len(addresses)) + for _, address := range addresses { + if address != nil { + result = append(result, cloneUDPAddr(address)) + } + } + return result +} + +func parsePort(value string) (int, error) { + port, err := strconv.Atoi(value) + if err != nil || port < 1 || port > 65535 { + return 0, errors.New("ike: invalid UDP port") + } + return port, nil +} diff --git a/internal/vowifi/ike/transport_test.go b/internal/vowifi/ike/transport_test.go new file mode 100644 index 0000000..1d13979 --- /dev/null +++ b/internal/vowifi/ike/transport_test.go @@ -0,0 +1,360 @@ +package ike + +import ( + "context" + "encoding/binary" + "errors" + "net" + "testing" + "time" +) + +type deadlineError struct{} + +func (deadlineError) Error() string { return "deadline" } +func (deadlineError) Timeout() bool { return true } +func (deadlineError) Temporary() bool { return true } + +func TestRoundTripDatagramWaitsBeyondFirst500Milliseconds(t *testing.T) { + available := make(chan struct{}) + go func() { + time.Sleep(700 * time.Millisecond) + close(available) + }() + writes := 0 + started := time.Now() + response, err := roundTripDatagram( + context.Background(), + 2*time.Second, + func([]byte) error { + writes++ + return nil + }, + func(buffer []byte, deadline time.Time) (int, error) { + select { + case <-available: + copy(buffer, []byte("response")) + return len("response"), nil + case <-time.After(time.Until(deadline)): + return 0, deadlineError{} + } + }, + []byte("request"), + ) + if err != nil { + t.Fatalf("roundTripDatagram() error = %v", err) + } + if string(response) != "response" || writes < 2 { + t.Fatalf("response=%q writes=%d", response, writes) + } + if elapsed := time.Since(started); elapsed < 650*time.Millisecond { + t.Fatalf("round trip returned too early after %v", elapsed) + } +} + +func TestRoundTripDatagramHonorsTotalTimeout(t *testing.T) { + started := time.Now() + _, err := roundTripDatagram( + context.Background(), + 120*time.Millisecond, + func([]byte) error { return nil }, + func(_ []byte, deadline time.Time) (int, error) { + time.Sleep(time.Until(deadline)) + return 0, deadlineError{} + }, + []byte("request"), + ) + if err == nil { + t.Fatal("roundTripDatagram() accepted a missing response") + } + elapsed := time.Since(started) + if elapsed < 100*time.Millisecond || elapsed > 400*time.Millisecond { + t.Fatalf("total timeout elapsed = %v, want approximately 120ms", elapsed) + } +} + +func TestSOCKS5UDPDatagramRoundTrip(t *testing.T) { + remote := &net.UDPAddr{IP: net.IPv4(203, 0, 113, 7), Port: 4500} + encoded, err := marshalSOCKS5Datagram(remote, []byte{1, 2, 3, 4}) + if err != nil { + t.Fatal(err) + } + payload, decoded, err := parseSOCKS5Datagram(encoded) + if err != nil { + t.Fatal(err) + } + if !decoded.IP.Equal(remote.IP) || decoded.Port != remote.Port || string(payload) != string([]byte{1, 2, 3, 4}) { + t.Fatalf("decoded SOCKS datagram = %v %v %x", decoded, remote, payload) + } + fragmented := append([]byte(nil), encoded...) + fragmented[2] = 1 + if _, _, err := parseSOCKS5Datagram(fragmented); err == nil { + t.Fatal("fragmented SOCKS5 UDP datagram was accepted") + } +} + +func TestSOCKS5UDPAssociateDomainReplyIsResolved(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + go func() { + reply := []byte{5, 0, 0, 3, byte(len("localhost"))} + reply = append(reply, "localhost"...) + var port [2]byte + binary.BigEndian.PutUint16(port[:], 7897) + reply = append(reply, port[:]...) + _, _ = server.Write(reply) + }() + address, err := readSOCKS5Reply(context.Background(), client, net.DefaultResolver) + if err != nil { + t.Fatalf("readSOCKS5Reply() error = %v", err) + } + if address.IP == nil || address.Port != 7897 { + t.Fatalf("resolved relay = %v", address) + } +} + +func TestSOCKS5InitialExchangeFallsBackAcrossResolvedEPDGAddresses(t *testing.T) { + relay, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer relay.Close() + connection, err := net.DialUDP("udp", nil, relay.LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + first := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 10), Port: 500} + second := &net.UDPAddr{IP: net.IPv4(192, 0, 2, 20), Port: 500} + transport := &socks5UDP{ + config: transportConfig{Timeout: 80 * time.Millisecond}, + udp: connection, + remote: cloneUDPAddr(first), + remotes: cloneUDPAddrs([]*net.UDPAddr{first, second}), + } + defer transport.Close() + requestHeader := ikeHeader{ + InitiatorSPI: [8]byte{1, 2, 3, 4, 5, 6, 7, 8}, + Exchange: exchangeIKEInit, + Flags: flagInitiator, + } + request := requestHeader.marshal([]byte("request")) + response := ikeHeader{ + InitiatorSPI: requestHeader.InitiatorSPI, + ResponderSPI: [8]byte{8, 7, 6, 5, 4, 3, 2, 1}, + Exchange: exchangeIKEInit, + Flags: flagResponse, + }.marshal([]byte("response")) + serverDone := make(chan error, 1) + go func() { + buffer := make([]byte, 2048) + for { + n, peer, readErr := relay.ReadFromUDP(buffer) + if readErr != nil { + serverDone <- readErr + return + } + _, destination, parseErr := parseSOCKS5Datagram(buffer[:n]) + if parseErr != nil { + serverDone <- parseErr + return + } + if !destination.IP.Equal(second.IP) { + continue + } + wire, marshalErr := marshalSOCKS5Datagram(second, response) + if marshalErr == nil { + _, marshalErr = relay.WriteToUDP(wire, peer) + } + serverDone <- marshalErr + return + } + }() + + got, err := transport.RoundTrip(context.Background(), request) + if err != nil { + t.Fatalf("RoundTrip() error = %v", err) + } + if string(got) != string(response) { + t.Fatalf("RoundTrip() response = %x", got) + } + if !transport.RemoteAddr().IP.Equal(second.IP) { + t.Fatalf("selected ePDG = %v, want %v", transport.RemoteAddr(), second) + } + if err := <-serverDone; err != nil { + t.Fatalf("relay: %v", err) + } +} + +func TestSOCKS5RoundTripSkipsStaleAndESPDatagrams(t *testing.T) { + relay, err := net.ListenUDP( + "udp", + &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}, + ) + if err != nil { + t.Fatal(err) + } + defer relay.Close() + connection, err := net.DialUDP( + "udp", + nil, + relay.LocalAddr().(*net.UDPAddr), + ) + if err != nil { + t.Fatal(err) + } + remote := &net.UDPAddr{IP: net.IPv4(203, 0, 113, 7), Port: 4500} + transport := &socks5UDP{ + config: transportConfig{Timeout: time.Second}, + udp: connection, + remote: cloneUDPAddr(remote), + floated: true, + } + defer transport.Close() + requestHeader := 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: 3, + } + request := requestHeader.marshal([]byte("request")) + validResponse := ikeHeader{ + InitiatorSPI: requestHeader.InitiatorSPI, + ResponderSPI: requestHeader.ResponderSPI, + Exchange: requestHeader.Exchange, + Flags: flagResponse, + MessageID: requestHeader.MessageID, + }.marshal([]byte("response")) + + serverDone := make(chan error, 1) + go func() { + buffer := make([]byte, 2048) + _, peer, err := relay.ReadFromUDP(buffer) + if err != nil { + serverDone <- err + return + } + stale, err := marshalSOCKS5Datagram( + &net.UDPAddr{IP: remote.IP, Port: 500}, + append([]byte{0, 0, 0, 0}, []byte("stale")...), + ) + if err != nil { + serverDone <- err + return + } + if _, err := relay.WriteToUDP(stale, peer); err != nil { + serverDone <- err + return + } + esp, err := marshalSOCKS5Datagram( + remote, + []byte{1, 2, 3, 4, 5, 6, 7, 8}, + ) + if err != nil { + serverDone <- err + return + } + if _, err := relay.WriteToUDP(esp, peer); err != nil { + serverDone <- err + return + } + staleIKE := ikeHeader{ + InitiatorSPI: requestHeader.InitiatorSPI, + ResponderSPI: requestHeader.ResponderSPI, + Exchange: requestHeader.Exchange, + Flags: flagResponse, + MessageID: requestHeader.MessageID - 1, + }.marshal([]byte("stale IKE")) + staleIKE, err = marshalSOCKS5Datagram( + remote, + append([]byte{0, 0, 0, 0}, staleIKE...), + ) + if err != nil { + serverDone <- err + return + } + if _, err := relay.WriteToUDP(staleIKE, peer); err != nil { + serverDone <- err + return + } + valid, err := marshalSOCKS5Datagram( + remote, + append([]byte{0, 0, 0, 0}, validResponse...), + ) + if err == nil { + _, err = relay.WriteToUDP(valid, peer) + } + serverDone <- err + }() + + response, err := transport.RoundTrip( + context.Background(), + request, + ) + if err != nil { + t.Fatalf("RoundTrip() error = %v", err) + } + if string(response) != string(validResponse) { + t.Fatalf("RoundTrip() response = %x", response) + } + if err := <-serverDone; err != nil { + t.Fatalf("relay: %v", err) + } +} + +func TestSessionReadDoesNotBlockIndependentWrite(t *testing.T) { + server, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + if err != nil { + t.Fatal(err) + } + defer server.Close() + connection, err := net.DialUDP("udp", nil, server.LocalAddr().(*net.UDPAddr)) + if err != nil { + t.Fatal(err) + } + transport := &directUDP{ + config: transportConfig{Timeout: time.Second}, + conn: connection, + remote: cloneUDPAddr(server.LocalAddr().(*net.UDPAddr)), + floated: true, + } + defer transport.Close() + readDone := make(chan error, 1) + go func() { + buffer := make([]byte, 64) + _, _, err := transport.ReceiveSessionPacket(context.Background(), buffer) + readDone <- err + }() + time.Sleep(30 * time.Millisecond) + started := time.Now() + if err := transport.SendSessionPacket(context.Background(), []byte{1, 2, 3, 4, 5, 6, 7, 8}, false); err != nil { + t.Fatalf("SendSessionPacket() error = %v", err) + } + if elapsed := time.Since(started); elapsed > 200*time.Millisecond { + t.Fatalf("session write blocked behind reader for %v", elapsed) + } + buffer := make([]byte, 64) + _ = server.SetReadDeadline(time.Now().Add(time.Second)) + n, _, err := server.ReadFromUDP(buffer) + if err != nil { + t.Fatal(err) + } + if n != 8 { + t.Fatalf("server received %d bytes, want 8", n) + } + _ = transport.Close() + select { + case err := <-readDone: + if err == nil || (!errors.Is(err, net.ErrClosed) && !isNetworkClose(err)) { + t.Fatalf("reader close error = %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("session reader did not wake after Close") + } +} + +func isNetworkClose(err error) bool { + var networkError net.Error + return errors.As(err, &networkError) +} diff --git a/internal/vowifi/ike/userspace_linux.go b/internal/vowifi/ike/userspace_linux.go new file mode 100644 index 0000000..c97a935 --- /dev/null +++ b/internal/vowifi/ike/userspace_linux.go @@ -0,0 +1,620 @@ +//go:build linux + +package ike + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/sys/unix" +) + +const userspaceTunnelMTU = 1380 + +type linuxUserspaceInstaller struct { + ipCommand string +} + +type linuxUserspaceHandle struct { + ipCommand string + config ChildSAConfig + tunnel *espTunnel + tun *os.File + relay NATTPacketRelay + + runContext context.Context + cancel context.CancelFunc + wait sync.WaitGroup + cancelOnce sync.Once + closeOnce sync.Once + + mu sync.Mutex + closed bool + terminalErr error + failures chan error + cleanup []ipCleanupCommand +} + +type ipCleanupCommand struct { + operation string + arguments []string +} + +func (*linuxUserspaceHandle) DataplaneMode() string { return "userspace" } + +func (installer linuxUserspaceInstaller) Install( + ctx context.Context, + config ChildSAConfig, +) (ChildSAHandle, error) { + if ctx == nil { + ctx = context.Background() + } + if config.Relay == nil { + return nil, errors.New("ike: user-space ESP requires a NAT-T packet relay") + } + if !config.UDPEncapsulation { + return nil, errors.New("ike: user-space ESP relay requires negotiated UDP encapsulation") + } + if len(config.PCSCF) == 0 { + return nil, errors.New("ike: user-space ESP requires at least one negotiated P-CSCF address") + } + if err := validateUserspaceRoutes(config); err != nil { + return nil, err + } + command := strings.TrimSpace(installer.ipCommand) + if command == "" { + command = "ip" + } + if _, err := exec.LookPath(command); err != nil { + return nil, errors.New("Linux iproute2 is required to configure the user-space CHILD_SA") + } + tunnel, err := newESPTunnel(config, nil) + if err != nil { + return nil, err + } + tun, actualName, err := openLinuxTUN(config.Name) + if err != nil { + return nil, err + } + config.Name = actualName + runContext, cancel := context.WithCancel(context.Background()) + handle := &linuxUserspaceHandle{ + ipCommand: command, + config: cloneChildSAConfig(config), + tunnel: tunnel, + tun: tun, + relay: config.Relay, + runContext: runContext, + cancel: cancel, + failures: make(chan error, 1), + } + if err := handle.configure(ctx); err != nil { + cancel() + handle.cleanupNetwork(context.Background()) + _ = tun.Close() + return nil, err + } + handle.wait.Add(2) + go handle.copyTUNToRelay() + go handle.copyRelayToTUN() + return handle, nil +} + +func openLinuxTUN(name string) (*os.File, string, error) { + name = strings.TrimSpace(name) + if name == "" { + return nil, "", errors.New("ike: TUN interface name is required") + } + request, err := unix.NewIfreq(name) + if err != nil { + return nil, "", fmt.Errorf("ike: invalid TUN interface name: %w", err) + } + request.SetUint16(uint16(unix.IFF_TUN | unix.IFF_NO_PI)) + descriptor, err := unix.Open("/dev/net/tun", unix.O_RDWR|unix.O_CLOEXEC, 0) + if err != nil { + return nil, "", fmt.Errorf("ike: open /dev/net/tun: %w", err) + } + if err := unix.IoctlIfreq(descriptor, unix.TUNSETIFF, request); err != nil { + _ = unix.Close(descriptor) + return nil, "", fmt.Errorf("ike: create TUN interface: %w", err) + } + file := os.NewFile(uintptr(descriptor), "/dev/net/tun:"+request.Name()) + if file == nil { + _ = unix.Close(descriptor) + return nil, "", errors.New("ike: create TUN file handle") + } + return file, request.Name(), nil +} + +func validateUserspaceRoutes(config ChildSAConfig) error { + if config.InnerLocalIPv4 == nil && config.InnerLocalIPv6 == nil { + return errors.New("ike: user-space ESP requires an assigned inner address") + } + validLocal := func(ip net.IP) bool { + return ip != nil && + !ip.IsUnspecified() && + !ip.IsMulticast() && + ipAllowedBySelectors(ip, config.InitiatorSelectors) + } + if config.InnerLocalIPv4 != nil && !validLocal(config.InnerLocalIPv4) { + return errors.New("ike: assigned inner IPv4 address is outside initiator traffic selectors") + } + if config.InnerLocalIPv6 != nil && !validLocal(config.InnerLocalIPv6) { + return errors.New("ike: assigned inner IPv6 address is outside initiator traffic selectors") + } + matchingFamily := false + for _, pcscf := range config.PCSCF { + if pcscf == nil || pcscf.IsUnspecified() || pcscf.IsMulticast() { + return errors.New("ike: P-CSCF address is invalid") + } + if !ipAllowedBySelectors(pcscf, config.ResponderSelectors) { + return fmt.Errorf("ike: P-CSCF %s is outside responder traffic selectors", pcscf) + } + if (pcscf.To4() != nil && config.InnerLocalIPv4 != nil) || + (pcscf.To4() == nil && pcscf.To16() != nil && config.InnerLocalIPv6 != nil) { + matchingFamily = true + } + } + if !matchingFamily { + return errors.New("ike: no P-CSCF address matches an assigned inner address family") + } + return nil +} + +func ipAllowedBySelectors(ip net.IP, selectors []trafficSelector) bool { + for _, selector := range selectors { + if ipWithinRange(ip, selector.StartIP, selector.EndIP) { + return true + } + } + return false +} + +func (handle *linuxUserspaceHandle) configure(ctx context.Context) error { + name := handle.config.Name + if handle.config.InnerLocalIPv4 != nil { + if err := handle.run( + ctx, + "assign TUN IPv4 address", + "-4", "address", "add", + handle.config.InnerLocalIPv4.String()+"/32", + "dev", name, + "noprefixroute", + ); err != nil { + return err + } + } + if handle.config.InnerLocalIPv6 != nil { + prefix := handle.config.InnerIPv6Prefix + if prefix == 0 || prefix > 128 { + prefix = 128 + } + if err := handle.run( + ctx, + "assign TUN IPv6 address", + "-6", "address", "add", + fmt.Sprintf("%s/%d", handle.config.InnerLocalIPv6.String(), prefix), + "dev", name, + "noprefixroute", + ); err != nil { + return err + } + } + if err := handle.run( + ctx, + "enable TUN interface", + "link", "set", "dev", name, + "mtu", strconv.Itoa(userspaceTunnelMTU), + "up", + ); err != nil { + return err + } + + table, priority := userspaceRoutingIdentifiers(handle.config.InboundSPI) + if handle.config.InnerLocalIPv4 != nil { + if err := handle.configureFamily( + ctx, + "-4", + handle.config.InnerLocalIPv4, + handle.ipv4PCSCF(), + 32, + table, + priority, + ); err != nil { + return err + } + } + if handle.config.InnerLocalIPv6 != nil { + if err := handle.configureFamily( + ctx, + "-6", + handle.config.InnerLocalIPv6, + handle.ipv6PCSCF(), + 128, + table, + priority, + ); err != nil { + return err + } + } + return nil +} + +func (handle *linuxUserspaceHandle) configureFamily( + ctx context.Context, + family string, + local net.IP, + pcscf []net.IP, + bits int, + table uint32, + priority uint32, +) error { + if len(pcscf) == 0 { + return nil + } + tableValue := strconv.FormatUint(uint64(table), 10) + priorityValue := strconv.FormatUint(uint64(priority), 10) + localPrefix := fmt.Sprintf("%s/%d", local.String(), bits) + if err := handle.requireUnusedRoutingSlot( + ctx, + family, + tableValue, + priorityValue, + ); err != nil { + return err + } + ruleArguments := []string{ + family, "rule", "add", + "priority", priorityValue, + "from", localPrefix, + "lookup", tableValue, + } + if err := handle.run(ctx, "install fail-closed source rule", ruleArguments...); err != nil { + return err + } + handle.recordCleanup( + "remove fail-closed source rule", + family, "rule", "delete", + "priority", priorityValue, + "from", localPrefix, + "lookup", tableValue, + ) + + unreachableArguments := []string{ + family, "route", "add", + "table", tableValue, + "unreachable", "default", + } + if err := handle.run(ctx, "install fail-closed route", unreachableArguments...); err != nil { + return err + } + handle.recordCleanup( + "remove fail-closed route", + family, "route", "delete", + "table", tableValue, + "unreachable", "default", + ) + + for _, address := range pcscf { + hostPrefix := fmt.Sprintf("%s/%d", address.String(), bits) + routeArguments := []string{ + family, "route", "add", + "table", tableValue, + hostPrefix, + "dev", handle.config.Name, + "src", local.String(), + } + if err := handle.run(ctx, "install P-CSCF host route", routeArguments...); err != nil { + return err + } + handle.recordCleanup( + "remove P-CSCF host route", + family, "route", "delete", + "table", tableValue, + hostPrefix, + "dev", handle.config.Name, + "src", local.String(), + ) + } + return nil +} + +func userspaceRoutingIdentifiers(spi uint32) (table uint32, priority uint32) { + table = spi + if table <= 255 { + table |= 0x80000000 + } + // Linux evaluates policy rules from the lowest numeric priority upward. + // The built-in main/default rules are 32766/32767, so a full-width SPI + // used directly as the priority would usually run too late and leak the + // inner source through the host's default route. Keep a SPI-derived slot + // strictly ahead of main; requireUnusedRoutingSlot rejects collisions. + priority = 10000 + spi%20000 + return table, priority +} + +func (handle *linuxUserspaceHandle) requireUnusedRoutingSlot( + ctx context.Context, + family string, + table string, + priority string, +) error { + routeCommand := exec.CommandContext( + ctx, + handle.ipCommand, + family, "-j", "route", "show", "table", "all", + ) + routeOutput, routeErr := routeCommand.CombinedOutput() + if routeErr != nil { + message := strings.TrimSpace(string(routeOutput)) + if message == "" { + message = routeErr.Error() + } + return fmt.Errorf("ike: inspect routing table %s: %s", table, message) + } + var routes []map[string]any + if err := json.Unmarshal(routeOutput, &routes); err != nil { + return fmt.Errorf("ike: parse Linux routing table inventory: %w", err) + } + for _, route := range routes { + value, exists := route["table"] + if !exists { + continue + } + if routingTableValue(value) == table { + return fmt.Errorf("ike: routing table %s is already in use", table) + } + } + + ruleCommand := exec.CommandContext(ctx, handle.ipCommand, family, "rule", "show") + ruleOutput, err := ruleCommand.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(ruleOutput)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("ike: inspect policy rules: %s", message) + } + prefix := priority + ":" + for _, line := range strings.Split(string(ruleOutput), "\n") { + fields := strings.Fields(line) + if strings.HasPrefix(strings.TrimSpace(line), prefix) || + containsAdjacentFields(fields, "lookup", table) { + return fmt.Errorf("ike: policy rule priority %s is already in use", priority) + } + } + return nil +} + +func routingTableValue(value any) string { + switch typed := value.(type) { + case float64: + if typed >= 0 && typed <= float64(^uint32(0)) { + return strconv.FormatUint(uint64(typed), 10) + } + case string: + return typed + } + return "" +} + +func containsAdjacentFields(fields []string, first string, second string) bool { + for index := 0; index+1 < len(fields); index++ { + if fields[index] == first && fields[index+1] == second { + return true + } + } + return false +} + +func (handle *linuxUserspaceHandle) ipv4PCSCF() []net.IP { + var result []net.IP + seen := make(map[string]struct{}) + for _, address := range handle.config.PCSCF { + if address.To4() != nil { + if _, duplicate := seen[address.String()]; duplicate { + continue + } + result = append(result, append(net.IP(nil), address...)) + seen[address.String()] = struct{}{} + } + } + return result +} + +func (handle *linuxUserspaceHandle) ipv6PCSCF() []net.IP { + var result []net.IP + seen := make(map[string]struct{}) + for _, address := range handle.config.PCSCF { + if address.To4() == nil && address.To16() != nil { + if _, duplicate := seen[address.String()]; duplicate { + continue + } + result = append(result, append(net.IP(nil), address...)) + seen[address.String()] = struct{}{} + } + } + return result +} + +func (handle *linuxUserspaceHandle) run( + ctx context.Context, + operation string, + arguments ...string, +) error { + command := exec.CommandContext(ctx, handle.ipCommand, arguments...) + output, err := command.CombinedOutput() + if err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + return fmt.Errorf("ike: %s: %s", operation, message) + } + return nil +} + +func (handle *linuxUserspaceHandle) recordCleanup(operation string, arguments ...string) { + handle.cleanup = append(handle.cleanup, ipCleanupCommand{ + operation: operation, + arguments: append([]string(nil), arguments...), + }) +} + +func (handle *linuxUserspaceHandle) copyTUNToRelay() { + defer handle.wait.Done() + buffer := make([]byte, 65535) + for { + count, err := handle.tun.Read(buffer) + if err != nil { + if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) { + handle.fail(fmt.Errorf("ike: read TUN packet: %w", err)) + } + return + } + protected, err := handle.tunnel.seal(buffer[:count]) + if err != nil { + // The kernel may emit IPv6 DAD/link-local traffic when the TUN is + // brought up, and local processes may attempt unrelated routes. + // Traffic-selector enforcement is a filter, not a session failure. + if errors.Is(err, errESPPolicyDrop) { + continue + } + handle.fail(err) + return + } + if err := handle.relay.SendESP(handle.runContext, protected); err != nil { + if handle.runContext.Err() == nil { + handle.fail(fmt.Errorf("ike: relay outbound ESP: %w", err)) + } + return + } + } +} + +func (handle *linuxUserspaceHandle) copyRelayToTUN() { + defer handle.wait.Done() + buffer := make([]byte, 65535) + for { + count, err := handle.relay.ReceiveESP(handle.runContext, buffer) + if err != nil { + if handle.runContext.Err() == nil { + handle.fail(fmt.Errorf("ike: relay inbound ESP: %w", err)) + } + return + } + cleartext, err := handle.tunnel.open(buffer[:count]) + if err != nil { + // Invalid ICVs, replays, malformed padding, and packets outside the + // negotiated selectors are untrusted network input. Drop them + // without allowing a forged datagram to tear down the CHILD_SA. + continue + } + if err := writeFull(handle.tun, cleartext); err != nil { + if handle.runContext.Err() == nil && !errors.Is(err, os.ErrClosed) { + handle.fail(fmt.Errorf("ike: write TUN packet: %w", err)) + } + return + } + } +} + +func writeFull(destination io.Writer, packet []byte) error { + count, err := destination.Write(packet) + if err != nil { + return err + } + if count != len(packet) { + return io.ErrShortWrite + } + return nil +} + +func (handle *linuxUserspaceHandle) fail(err error) { + handle.mu.Lock() + notify := false + if handle.terminalErr == nil { + handle.terminalErr = err + notify = true + } + handle.mu.Unlock() + if notify { + select { + case handle.failures <- err: + default: + } + } + handle.cancelRun() +} + +func (handle *linuxUserspaceHandle) Failures() <-chan error { + return handle.failures +} + +func (handle *linuxUserspaceHandle) cancelRun() { + handle.cancelOnce.Do(func() { + handle.cancel() + }) +} + +func (handle *linuxUserspaceHandle) closeTUN() { + handle.closeOnce.Do(func() { + _ = handle.tun.Close() + }) +} + +func (handle *linuxUserspaceHandle) Close(ctx context.Context) error { + handle.mu.Lock() + if handle.closed { + handle.mu.Unlock() + return nil + } + handle.closed = true + handle.mu.Unlock() + + handle.cancelRun() + cleanupErr := handle.cleanupNetwork(ctx) + handle.closeTUN() + handle.wait.Wait() + // A terminal data-plane error is delivered exactly once through Failures. + // Close reports only teardown errors so the orchestrator does not record + // the same runtime cause again as a cleanup failure. + return cleanupErr +} + +func (handle *linuxUserspaceHandle) cleanupNetwork(ctx context.Context) error { + if ctx == nil || ctx.Err() != nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + var errs []error + for index := len(handle.cleanup) - 1; index >= 0; index-- { + item := handle.cleanup[index] + command := exec.CommandContext(ctx, handle.ipCommand, item.arguments...) + if output, err := command.CombinedOutput(); err != nil { + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + errs = append(errs, fmt.Errorf("ike: %s: %s", item.operation, message)) + } + } + handle.cleanup = nil + return errors.Join(errs...) +} + +var _ ChildSAInstaller = linuxUserspaceInstaller{} +var _ ChildSAHandle = (*linuxUserspaceHandle)(nil) +var _ DataplaneEvidence = (*linuxUserspaceHandle)(nil) +var _ DataplaneFailureNotifier = (*linuxUserspaceHandle)(nil) diff --git a/internal/vowifi/ike/userspace_linux_test.go b/internal/vowifi/ike/userspace_linux_test.go new file mode 100644 index 0000000..67883d0 --- /dev/null +++ b/internal/vowifi/ike/userspace_linux_test.go @@ -0,0 +1,209 @@ +//go:build linux + +package ike + +import ( + "context" + "errors" + "net" + "os" + "os/exec" + "strconv" + "strings" + "testing" + "time" +) + +func TestValidateUserspaceRoutesAllowsOnlyNegotiatedEndpoints(t *testing.T) { + t.Parallel() + config := userspaceRouteTestConfig() + if err := validateUserspaceRoutes(config); err != nil { + t.Fatalf("valid negotiated route: %v", err) + } + + config.PCSCF = []net.IP{net.IPv4(203, 0, 113, 10)} + if err := validateUserspaceRoutes(config); err == nil { + t.Fatal("P-CSCF outside responder selector was accepted") + } +} + +func TestValidateUserspaceRoutesRequiresMatchingAddressFamily(t *testing.T) { + t.Parallel() + config := userspaceRouteTestConfig() + config.PCSCF = []net.IP{net.ParseIP("2001:db8::20")} + config.ResponderSelectors = []trafficSelector{{ + StartPort: 0, + EndPort: 65535, + StartIP: net.ParseIP("2001:db8::"), + EndIP: net.ParseIP("2001:db8::ffff"), + }} + if err := validateUserspaceRoutes(config); err == nil { + t.Fatal("IPv6 P-CSCF without an assigned inner IPv6 address was accepted") + } +} + +func TestUserspaceRulePriorityAlwaysPrecedesMainRoute(t *testing.T) { + t.Parallel() + for _, spi := range []uint32{ + 0, + 1, + 32767, + 0x01020304, + 0x80000000, + 0xffffffff, + } { + table, priority := userspaceRoutingIdentifiers(spi) + if table <= 255 { + t.Fatalf("SPI %08x produced reserved routing table %d", spi, table) + } + if priority < 10000 || priority >= 30000 || priority >= 32766 { + t.Fatalf( + "SPI %08x produced unsafe rule priority %d", + spi, + priority, + ) + } + } +} + +func userspaceRouteTestConfig() ChildSAConfig { + return ChildSAConfig{ + InnerLocalIPv4: net.IPv4(10, 132, 116, 34), + PCSCF: []net.IP{net.IPv4(10, 127, 192, 82)}, + InitiatorSelectors: []trafficSelector{{ + StartPort: 0, + EndPort: 65535, + StartIP: net.IPv4(10, 132, 116, 34), + EndIP: net.IPv4(10, 132, 116, 34), + }}, + ResponderSelectors: []trafficSelector{{ + StartPort: 0, + EndPort: 65535, + StartIP: net.IPv4(10, 0, 0, 0), + EndIP: net.IPv4(10, 255, 255, 255), + }}, + } +} + +type blockingNATTRelay struct{} + +func (blockingNATTRelay) SendESP(ctx context.Context, _ []byte) error { + return ctx.Err() +} + +func (blockingNATTRelay) ReceiveESP(ctx context.Context, _ []byte) (int, error) { + <-ctx.Done() + return 0, ctx.Err() +} + +func TestLinuxUserspaceInstallerLifecycle(t *testing.T) { + if os.Getenv("VOCAT_NETNS_TEST") != "1" { + t.Skip("set VOCAT_NETNS_TEST=1 inside an isolated Linux network namespace") + } + config := userspaceRouteTestConfig() + config.Name = "vocat-swu-test" + config.InboundSPI = 0x01020304 + config.OutboundSPI = 0x05060708 + config.Encryption = "aes-cbc-128" + config.Integrity = "hmac-sha1-96" + config.InboundEncKey = make([]byte, 16) + config.InboundAuthKey = make([]byte, 20) + config.OutboundEncKey = make([]byte, 16) + config.OutboundAuthKey = make([]byte, 20) + config.UDPEncapsulation = true + config.Relay = blockingNATTRelay{} + + tableID, _ := userspaceRoutingIdentifiers(config.InboundSPI) + table := strconv.FormatUint(uint64(tableID), 10) + if output, err := exec.Command( + "ip", "-4", "route", "add", + "table", table, "unreachable", "default", + ).CombinedOutput(); err != nil { + t.Fatalf("preoccupy route table: %v: %s", err, output) + } + if conflicting, err := (linuxUserspaceInstaller{ipCommand: "ip"}).Install( + context.Background(), + config, + ); err == nil { + _ = conflicting.Close(context.Background()) + t.Fatal("installer accepted a preoccupied routing table") + } + if output, err := exec.Command( + "ip", "-4", "route", "delete", + "table", table, "unreachable", "default", + ).CombinedOutput(); err != nil { + t.Fatalf("release preoccupied route table: %v: %s", err, output) + } + + handle, err := (linuxUserspaceInstaller{ipCommand: "ip"}).Install( + context.Background(), + config, + ) + if err != nil { + t.Fatalf("install user-space CHILD_SA: %v", err) + } + if mode := handle.(DataplaneEvidence).DataplaneMode(); mode != "userspace" { + t.Fatalf("dataplane mode = %q", mode) + } + if output, err := exec.Command("ip", "link", "show", "dev", config.Name).CombinedOutput(); err != nil { + t.Fatalf("TUN interface was not created: %v: %s", err, output) + } + if output, err := exec.Command( + "ip", "-4", "route", "show", "table", table, + ).CombinedOutput(); err != nil || + !strings.Contains(string(output), "10.127.192.82") || + !strings.Contains(string(output), "unreachable default") { + t.Fatalf("isolated P-CSCF routes are missing: %v: %s", err, output) + } + if output, err := exec.Command( + "ip", "-4", "route", "get", "10.127.192.82", + "from", "10.132.116.34", + ).CombinedOutput(); err != nil || + !strings.Contains(string(output), "dev "+config.Name) || + !strings.Contains(string(output), "table "+table) { + t.Fatalf( + "P-CSCF route did not win before the main table: %v: %s", + err, + output, + ) + } + if output, err := exec.Command( + "ip", "-4", "route", "get", "198.51.100.1", + "from", "10.132.116.34", + ).CombinedOutput(); err == nil { + t.Fatalf( + "non-P-CSCF inner traffic escaped the unreachable default: %s", + output, + ) + } + + expectedFailure := errors.New("test relay stopped") + concrete := handle.(*linuxUserspaceHandle) + concrete.fail(expectedFailure) + select { + case failure := <-concrete.Failures(): + if !errors.Is(failure, expectedFailure) { + t.Fatalf("runtime failure = %v", failure) + } + case <-time.After(time.Second): + t.Fatal("terminal failure was not published") + } + + closeContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := handle.Close(closeContext); err != nil { + t.Fatalf("close user-space CHILD_SA: %v", err) + } + if err := exec.Command("ip", "link", "show", "dev", config.Name).Run(); err == nil { + t.Fatal("TUN interface survived Close") + } + output, err := exec.Command("ip", "-4", "rule", "show").CombinedOutput() + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(output), "from 10.132.116.34") { + t.Fatalf("source rule survived Close: %s", output) + } +} + +var _ NATTPacketRelay = blockingNATTRelay{} diff --git a/internal/vowifi/ike/wire.go b/internal/vowifi/ike/wire.go new file mode 100644 index 0000000..6f1fd6d --- /dev/null +++ b/internal/vowifi/ike/wire.go @@ -0,0 +1,421 @@ +package ike + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" +) + +const ( + ikeHeaderLength = 28 + ikeMajorVersion = 2 + ikeMinorVersion = 0 + + exchangeIKEInit = 34 + exchangeIKEAuth = 35 + exchangeInformational = 37 + + flagInitiator = 0x08 + flagResponse = 0x20 + + payloadNone = 0 + payloadSA = 33 + payloadKE = 34 + payloadIDi = 35 + payloadIDr = 36 + payloadCert = 37 + payloadAuth = 39 + payloadNonce = 40 + payloadNotify = 41 + payloadTSi = 44 + payloadTSr = 45 + payloadEncrypted = 46 + payloadCP = 47 + payloadEAP = 48 + + protocolIKE = 1 + protocolESP = 3 + + transformEncryption = 1 + transformPRF = 2 + transformIntegrity = 3 + transformDH = 4 + transformESN = 5 + + encryptionAESCBC = 12 + + prfHMACSHA1 = 2 + prfHMACSHA256 = 5 + + integrityHMACSHA1_96 = 2 + integrityHMACSHA256_128 = 12 + dhMODP1024 = 2 + dhMODP2048 = 14 + transformAttributeKeyLen = 14 + + notifyNATSource = 16388 + notifyNATDestination = 16389 + notifyEAPOnlyAuth = 16417 + notifyInvalidKE = 17 + notifyNoProposal = 14 +) + +var ( + errMalformedPacket = errors.New("ike: malformed packet") + errUnexpectedPacket = errors.New("ike: unexpected packet") + errUnsupportedSuite = errors.New("ike: unsupported negotiated suite") + errIntegrityMismatch = errors.New("ike: encrypted payload integrity mismatch") +) + +type ikeHeader struct { + InitiatorSPI [8]byte + ResponderSPI [8]byte + NextPayload uint8 + Version uint8 + Exchange uint8 + Flags uint8 + MessageID uint32 + Length uint32 +} + +func (header ikeHeader) marshal(body []byte) []byte { + packet := make([]byte, ikeHeaderLength+len(body)) + copy(packet[0:8], header.InitiatorSPI[:]) + copy(packet[8:16], header.ResponderSPI[:]) + packet[16] = header.NextPayload + if header.Version == 0 { + header.Version = ikeMajorVersion<<4 | ikeMinorVersion + } + packet[17] = header.Version + packet[18] = header.Exchange + packet[19] = header.Flags + binary.BigEndian.PutUint32(packet[20:24], header.MessageID) + binary.BigEndian.PutUint32(packet[24:28], uint32(len(packet))) + copy(packet[28:], body) + return packet +} + +func parseIKEPacket(packet []byte) (ikeHeader, []byte, error) { + if len(packet) < ikeHeaderLength { + return ikeHeader{}, nil, fmt.Errorf("%w: header is truncated", errMalformedPacket) + } + var header ikeHeader + copy(header.InitiatorSPI[:], packet[0:8]) + copy(header.ResponderSPI[:], packet[8:16]) + header.NextPayload = packet[16] + header.Version = packet[17] + header.Exchange = packet[18] + header.Flags = packet[19] + header.MessageID = binary.BigEndian.Uint32(packet[20:24]) + header.Length = binary.BigEndian.Uint32(packet[24:28]) + if header.Version>>4 != ikeMajorVersion { + return ikeHeader{}, nil, fmt.Errorf("%w: unsupported IKE major version %d", errMalformedPacket, header.Version>>4) + } + if header.Length < ikeHeaderLength || uint64(header.Length) != uint64(len(packet)) { + return ikeHeader{}, nil, fmt.Errorf("%w: encoded length %d does not match datagram length %d", errMalformedPacket, header.Length, len(packet)) + } + return header, packet[ikeHeaderLength:], nil +} + +type payload struct { + Type uint8 + Critical bool + Body []byte +} + +func marshalPayloadChain(payloads []payload) (uint8, []byte, error) { + if len(payloads) == 0 { + return payloadNone, nil, nil + } + var output bytes.Buffer + for index, item := range payloads { + if item.Type == payloadNone || item.Type == payloadEncrypted { + return 0, nil, fmt.Errorf("ike: invalid ordinary payload type %d", item.Type) + } + next := uint8(payloadNone) + if index+1 < len(payloads) { + next = payloads[index+1].Type + } + length := 4 + len(item.Body) + if length > 65535 { + return 0, nil, errors.New("ike: payload exceeds 65535 bytes") + } + output.WriteByte(next) + if item.Critical { + output.WriteByte(0x80) + } else { + output.WriteByte(0) + } + var encodedLength [2]byte + binary.BigEndian.PutUint16(encodedLength[:], uint16(length)) + output.Write(encodedLength[:]) + output.Write(item.Body) + } + return payloads[0].Type, output.Bytes(), nil +} + +func parsePayloadChain(first uint8, encoded []byte) ([]payload, error) { + var result []payload + next := first + offset := 0 + for next != payloadNone { + if len(result) >= 64 { + return nil, fmt.Errorf("%w: too many chained payloads", errMalformedPacket) + } + if offset+4 > len(encoded) { + return nil, fmt.Errorf("%w: payload header is truncated", errMalformedPacket) + } + following := encoded[offset] + flags := encoded[offset+1] + length := int(binary.BigEndian.Uint16(encoded[offset+2 : offset+4])) + if length < 4 || offset+length > len(encoded) { + return nil, fmt.Errorf("%w: payload type %d has invalid length %d", errMalformedPacket, next, length) + } + body := append([]byte(nil), encoded[offset+4:offset+length]...) + result = append(result, payload{Type: next, Critical: flags&0x80 != 0, Body: body}) + offset += length + next = following + } + if offset != len(encoded) { + return nil, fmt.Errorf("%w: %d trailing payload bytes", errMalformedPacket, len(encoded)-offset) + } + return result, nil +} + +func payloadsOfType(payloads []payload, kind uint8) []payload { + var matches []payload + for _, item := range payloads { + if item.Type == kind { + matches = append(matches, item) + } + } + return matches +} + +func onePayload(payloads []payload, kind uint8) (payload, error) { + matches := payloadsOfType(payloads, kind) + if len(matches) != 1 { + return payload{}, fmt.Errorf("%w: expected one payload type %d, got %d", errUnexpectedPacket, kind, len(matches)) + } + return matches[0], nil +} + +type transform struct { + Type uint8 + ID uint16 + KeyLength int +} + +type proposal struct { + Number uint8 + Protocol uint8 + SPI []byte + Transforms []transform +} + +func marshalProposals(proposals []proposal) ([]byte, error) { + var output bytes.Buffer + for proposalIndex, item := range proposals { + if len(item.SPI) > 255 || len(item.Transforms) > 255 { + return nil, errors.New("ike: proposal has too many bytes or transforms") + } + var transforms bytes.Buffer + for transformIndex, candidate := range item.Transforms { + var attributes []byte + if candidate.KeyLength > 0 { + attributes = make([]byte, 4) + binary.BigEndian.PutUint16(attributes[0:2], 0x8000|transformAttributeKeyLen) + binary.BigEndian.PutUint16(attributes[2:4], uint16(candidate.KeyLength)) + } + length := 8 + len(attributes) + if transformIndex+1 < len(item.Transforms) { + transforms.WriteByte(3) + } else { + transforms.WriteByte(0) + } + transforms.WriteByte(0) + var header [6]byte + binary.BigEndian.PutUint16(header[0:2], uint16(length)) + header[2] = candidate.Type + header[3] = 0 + binary.BigEndian.PutUint16(header[4:6], candidate.ID) + transforms.Write(header[:]) + transforms.Write(attributes) + } + length := 8 + len(item.SPI) + transforms.Len() + if proposalIndex+1 < len(proposals) { + output.WriteByte(2) + } else { + output.WriteByte(0) + } + output.WriteByte(0) + var header [6]byte + binary.BigEndian.PutUint16(header[0:2], uint16(length)) + header[2] = item.Number + header[3] = item.Protocol + header[4] = uint8(len(item.SPI)) + header[5] = uint8(len(item.Transforms)) + output.Write(header[:]) + output.Write(item.SPI) + output.Write(transforms.Bytes()) + } + return output.Bytes(), nil +} + +func parseProposals(encoded []byte) ([]proposal, error) { + var result []proposal + offset := 0 + for { + if offset == len(encoded) { + break + } + if len(result) >= 16 || offset+8 > len(encoded) { + return nil, fmt.Errorf("%w: invalid SA proposal header", errMalformedPacket) + } + last := encoded[offset] + length := int(binary.BigEndian.Uint16(encoded[offset+2 : offset+4])) + spiSize := int(encoded[offset+6]) + transformCount := int(encoded[offset+7]) + if length < 8+spiSize || offset+length > len(encoded) { + return nil, fmt.Errorf("%w: invalid SA proposal length", errMalformedPacket) + } + item := proposal{ + Number: encoded[offset+4], + Protocol: encoded[offset+5], + SPI: append([]byte(nil), encoded[offset+8:offset+8+spiSize]...), + } + transformOffset := offset + 8 + spiSize + proposalEnd := offset + length + for transformOffset < proposalEnd { + if len(item.Transforms) >= 32 || transformOffset+8 > proposalEnd { + return nil, fmt.Errorf("%w: invalid transform header", errMalformedPacket) + } + transformLength := int(binary.BigEndian.Uint16(encoded[transformOffset+2 : transformOffset+4])) + if transformLength < 8 || transformOffset+transformLength > proposalEnd { + return nil, fmt.Errorf("%w: invalid transform length", errMalformedPacket) + } + transformEnd := transformOffset + transformLength + if transformEnd < proposalEnd && encoded[transformOffset] != 3 { + return nil, fmt.Errorf("%w: non-final transform has invalid chaining marker", errMalformedPacket) + } + if transformEnd == proposalEnd && encoded[transformOffset] != 0 { + return nil, fmt.Errorf("%w: final transform has invalid chaining marker", errMalformedPacket) + } + candidate := transform{ + Type: encoded[transformOffset+4], + ID: binary.BigEndian.Uint16(encoded[transformOffset+6 : transformOffset+8]), + } + attributes := encoded[transformOffset+8 : transformOffset+transformLength] + for len(attributes) > 0 { + if len(attributes) < 4 { + return nil, fmt.Errorf("%w: truncated transform attribute", errMalformedPacket) + } + attributeType := binary.BigEndian.Uint16(attributes[0:2]) + if attributeType&0x8000 != 0 { + if attributeType&0x7fff == transformAttributeKeyLen { + candidate.KeyLength = int(binary.BigEndian.Uint16(attributes[2:4])) + } + attributes = attributes[4:] + continue + } + attributeLength := int(binary.BigEndian.Uint16(attributes[2:4])) + if attributeLength < 0 || 4+attributeLength > len(attributes) { + return nil, fmt.Errorf("%w: invalid transform TLV attribute", errMalformedPacket) + } + if attributeType == transformAttributeKeyLen && attributeLength == 2 { + candidate.KeyLength = int(binary.BigEndian.Uint16(attributes[4:6])) + } + attributes = attributes[4+attributeLength:] + } + item.Transforms = append(item.Transforms, candidate) + transformOffset += transformLength + } + if transformOffset != proposalEnd || len(item.Transforms) != transformCount { + return nil, fmt.Errorf("%w: transform count mismatch", errMalformedPacket) + } + result = append(result, item) + offset = proposalEnd + if last == 0 { + if offset != len(encoded) { + return nil, fmt.Errorf("%w: bytes follow last proposal", errMalformedPacket) + } + break + } + if last != 2 { + return nil, fmt.Errorf("%w: invalid proposal chaining marker %d", errMalformedPacket, last) + } + } + if len(result) == 0 { + return nil, fmt.Errorf("%w: empty SA payload", errMalformedPacket) + } + return result, nil +} + +type negotiatedSuite struct { + EncryptionID uint16 + EncryptionBits int + PRFID uint16 + IntegrityID uint16 + DHID uint16 +} + +func parseIKESuite(item proposal) (negotiatedSuite, error) { + if item.Protocol != protocolIKE || len(item.SPI) != 0 { + return negotiatedSuite{}, fmt.Errorf("%w: responder selected a non-IKE proposal", errUnsupportedSuite) + } + var suite negotiatedSuite + seen := make(map[uint8]bool) + for _, candidate := range item.Transforms { + if seen[candidate.Type] { + return negotiatedSuite{}, fmt.Errorf("%w: duplicate transform type %d", errUnsupportedSuite, candidate.Type) + } + seen[candidate.Type] = true + switch candidate.Type { + case transformEncryption: + suite.EncryptionID = candidate.ID + suite.EncryptionBits = candidate.KeyLength + case transformPRF: + suite.PRFID = candidate.ID + case transformIntegrity: + suite.IntegrityID = candidate.ID + case transformDH: + suite.DHID = candidate.ID + default: + return negotiatedSuite{}, fmt.Errorf("%w: IKE transform type %d", errUnsupportedSuite, candidate.Type) + } + } + if suite.EncryptionID != encryptionAESCBC || (suite.EncryptionBits != 128 && suite.EncryptionBits != 256) { + return negotiatedSuite{}, fmt.Errorf("%w: encryption id=%d bits=%d", errUnsupportedSuite, suite.EncryptionID, suite.EncryptionBits) + } + if suite.PRFID != prfHMACSHA1 && suite.PRFID != prfHMACSHA256 { + return negotiatedSuite{}, fmt.Errorf("%w: PRF id=%d", errUnsupportedSuite, suite.PRFID) + } + if suite.IntegrityID != integrityHMACSHA1_96 && suite.IntegrityID != integrityHMACSHA256_128 { + return negotiatedSuite{}, fmt.Errorf("%w: integrity id=%d", errUnsupportedSuite, suite.IntegrityID) + } + if suite.DHID != dhMODP1024 && suite.DHID != dhMODP2048 { + return negotiatedSuite{}, fmt.Errorf("%w: DH id=%d", errUnsupportedSuite, suite.DHID) + } + return suite, nil +} + +func makeNotify(notifyType uint16, data []byte) payload { + body := make([]byte, 4+len(data)) + body[0] = 0 + body[1] = 0 + binary.BigEndian.PutUint16(body[2:4], notifyType) + copy(body[4:], data) + return payload{Type: payloadNotify, Body: body} +} + +func parseNotify(item payload) (uint16, []byte, error) { + if item.Type != payloadNotify || len(item.Body) < 4 { + return 0, nil, fmt.Errorf("%w: invalid notify payload", errMalformedPacket) + } + spiSize := int(item.Body[1]) + if 4+spiSize > len(item.Body) { + return 0, nil, fmt.Errorf("%w: truncated notify SPI", errMalformedPacket) + } + return binary.BigEndian.Uint16(item.Body[2:4]), append([]byte(nil), item.Body[4+spiSize:]...), nil +} diff --git a/internal/vowifi/ike/wire_auth_test.go b/internal/vowifi/ike/wire_auth_test.go new file mode 100644 index 0000000..d07b4bb --- /dev/null +++ b/internal/vowifi/ike/wire_auth_test.go @@ -0,0 +1,185 @@ +package ike + +import ( + "bytes" + "errors" + "testing" + + "vocat/internal/vowifi" +) + +func TestPayloadAndProposalWireRoundTrip(t *testing.T) { + offered := proposal{ + Number: 1, + Protocol: protocolIKE, + Transforms: []transform{ + {Type: transformEncryption, ID: encryptionAESCBC, KeyLength: 128}, + {Type: transformPRF, ID: prfHMACSHA1}, + {Type: transformIntegrity, ID: integrityHMACSHA1_96}, + {Type: transformDH, ID: dhMODP1024}, + }, + } + sa, err := marshalProposals([]proposal{offered}) + if err != nil { + t.Fatalf("marshalProposals() error = %v", err) + } + first, wire, err := marshalPayloadChain([]payload{ + {Type: payloadSA, Body: sa}, + {Type: payloadNonce, Body: bytes.Repeat([]byte{0xaa}, 32)}, + }) + if err != nil { + t.Fatalf("marshalPayloadChain() error = %v", err) + } + if first != payloadSA || len(wire) < 8 || wire[0] != payloadNonce { + t.Fatalf("unexpected payload chain header: first=%d wire=%x", first, wire[:8]) + } + decoded, err := parsePayloadChain(first, wire) + if err != nil { + t.Fatalf("parsePayloadChain() error = %v", err) + } + if len(decoded) != 2 || decoded[0].Type != payloadSA || decoded[1].Type != payloadNonce { + t.Fatalf("decoded payload chain = %#v", decoded) + } + proposals, err := parseProposals(decoded[0].Body) + if err != nil { + t.Fatalf("parseProposals() error = %v", err) + } + if len(proposals) != 1 || len(proposals[0].Transforms) != 4 || + proposals[0].Transforms[0].KeyLength != 128 { + t.Fatalf("decoded proposal = %#v", proposals) + } +} + +func TestInitialEAPOnlyAuthCarriesAPNIDrAndNotify(t *testing.T) { + idi := payload{Type: payloadIDi, Body: []byte{3, 0, 0, 0, 'u'}} + idr := payload{Type: payloadIDr, Body: []byte{2, 0, 0, 0, 'i', 'm', 's'}} + payloads := buildInitialEAPOnlyAuth( + idi, + idr, + []byte{1, 2, 3}, + dualStackTrafficSelectors(payloadTSi), + dualStackTrafficSelectors(payloadTSr), + ) + if len(payloads) != 7 || payloads[0].Type != payloadIDi || payloads[1].Type != payloadIDr { + t.Fatalf("initial auth payload order = %#v", payloads) + } + if got := string(payloads[1].Body[4:]); got != "ims" || payloads[1].Body[0] != 2 { + t.Fatalf("IDr = type %d value %q, want ID_FQDN ims", payloads[1].Body[0], got) + } + kind, data, err := parseNotify(payloads[2]) + if err != nil { + t.Fatalf("parseNotify() error = %v", err) + } + if kind != notifyEAPOnlyAuth || len(data) != 0 { + t.Fatalf("notify = %d/%x, want EAP_ONLY_AUTHENTICATION", kind, data) + } + for _, kind := range []uint8{payloadTSi, payloadTSr} { + item, err := onePayload(payloads, kind) + if err != nil { + t.Fatalf("onePayload(%d) error = %v", kind, err) + } + selectors, err := parseTrafficSelectors(item) + if err != nil { + t.Fatalf("parseTrafficSelectors(%d) error = %v", kind, err) + } + if len(selectors) != 2 || selectors[0].StartIP.To4() == nil || selectors[1].StartIP.To4() != nil { + t.Fatalf("dual-stack selectors = %#v", selectors) + } + } +} + +func TestResponderIDrValidatorsSeparateEPDGAndAPN(t *testing.T) { + epdg := payload{ + Type: payloadIDr, + Body: append([]byte{2, 0, 0, 0}, []byte("epdg.epc.mnc015.mcc234.pub.3gppnetwork.org")...), + } + if err := validateFQDNIDr(epdg, "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org", "initial ePDG"); err != nil { + t.Fatalf("valid initial IDr rejected: %v", err) + } + apn := payload{Type: payloadIDr, Body: []byte{2, 0, 0, 0, 'i', 'm', 's'}} + if err := validateFQDNIDr(apn, "ims", "final APN"); err != nil { + t.Fatalf("valid final IDr rejected: %v", err) + } + wrongType := apn + wrongType.Body = append([]byte(nil), apn.Body...) + wrongType.Body[0] = 11 + if err := validateFQDNIDr(wrongType, "ims", "final APN"); err == nil { + t.Fatal("non-ID_FQDN final IDr was accepted") + } + if err := validateFQDNIDr(epdg, "ims", "final APN"); err == nil { + t.Fatal("ePDG identity was accepted as the APN identity") + } +} + +func TestFinalEAPResponderAUTHValidAndInvalidNeverAllowed(t *testing.T) { + suite := legacyTestSuite() + msk := bytes.Repeat([]byte{0x10}, 64) + initialResponse := bytes.Repeat([]byte{0x20}, 96) + initiatorNonce := bytes.Repeat([]byte{0x30}, 32) + skpr := bytes.Repeat([]byte{0x40}, 20) + idr := payload{Type: payloadIDr, Body: append([]byte{2, 0, 0, 0}, []byte("epdg.example")...)} + signed, err := responderSignedOctets(initialResponse, initiatorNonce, suite, skpr, idr) + if err != nil { + t.Fatalf("responderSignedOctets() error = %v", err) + } + padded, _ := prf(suite, msk, []byte("Key Pad for IKEv2")) + value, _ := prf(suite, padded, signed) + auth := payload{Type: payloadAuth, Body: append([]byte{authMethodSharedKeyMIC, 0, 0, 0}, value...)} + if err := verifyEAPResponderAUTH(auth, msk, initialResponse, initiatorNonce, suite, skpr, idr); err != nil { + t.Fatalf("valid final responder AUTH rejected: %v", err) + } + auth.Body[len(auth.Body)-1] ^= 1 + if err := verifyEAPResponderAUTH(auth, msk, initialResponse, initiatorNonce, suite, skpr, idr); err == nil { + t.Fatal("invalid final responder AUTH was accepted") + } + + status, _, err := validateInitialResponderAUTH( + []payload{idr}, + initialResponse, + initiatorNonce, + suite, + skpr, + "epdg.example", + "epdg.example", + nil, + nil, + false, + ) + if status != vowifi.ResponderAUTHMissing || !errors.Is(err, vowifi.ErrResponderAUTHRequired) { + t.Fatalf("strict missing initial AUTH = status %q err %v", status, err) + } + status, _, err = validateInitialResponderAUTH( + []payload{idr}, + initialResponse, + initiatorNonce, + suite, + skpr, + "epdg.example", + "epdg.example", + nil, + nil, + true, + ) + if status != vowifi.ResponderAUTHMissing || err != nil { + t.Fatalf("EAP-only deferred initial AUTH = status %q err %v", status, err) + } +} + +func TestConfigurationIPv6PrefixIsMandatoryAndPreserved(t *testing.T) { + ipv6 := bytes.Repeat([]byte{0x20}, 16) + validBody := []byte{configReply, 0, 0, 0, 0, configInternalIPv6Address, 0, 17} + validBody = append(validBody, ipv6...) + validBody = append(validBody, 64) + configuration, err := parseConfiguration(payload{Type: payloadCP, Body: validBody}) + if err != nil { + t.Fatalf("parseConfiguration(valid IPv6) error = %v", err) + } + if configuration.IPv6Prefix != 64 || !bytes.Equal(configuration.LocalIPv6, ipv6) { + t.Fatalf("IPv6 configuration = %#v", configuration) + } + invalidBody := []byte{configReply, 0, 0, 0, 0, configInternalIPv6Address, 0, 16} + invalidBody = append(invalidBody, ipv6...) + if _, err := parseConfiguration(payload{Type: payloadCP, Body: invalidBody}); err == nil { + t.Fatal("16-byte INTERNAL_IP6_ADDRESS without prefix was accepted") + } +} diff --git a/internal/vowifi/ims/digest.go b/internal/vowifi/ims/digest.go new file mode 100644 index 0000000..71dda5a --- /dev/null +++ b/internal/vowifi/ims/digest.go @@ -0,0 +1,320 @@ +package ims + +import ( + "context" + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + + "vocat/internal/vowifi" +) + +type digestChallenge struct { + Realm string + Nonce string + Opaque string + Algorithm string + QOP string + Stale bool + Proxy bool +} + +type digestCredentials struct { + Username string + Password []byte + AUTS string + URI string + Method string + CNonce string + NC uint32 +} + +func parseDigestChallenge(value string, proxy bool) (digestChallenge, error) { + scheme, parameters, found := strings.Cut(strings.TrimSpace(value), " ") + if !found || !strings.EqualFold(scheme, "Digest") { + return digestChallenge{}, errors.New("ims: unsupported SIP authentication scheme") + } + directives, err := parseAuthDirectives(parameters) + if err != nil { + return digestChallenge{}, err + } + challenge := digestChallenge{ + Realm: directives["realm"], + Nonce: directives["nonce"], + Opaque: directives["opaque"], + Algorithm: directives["algorithm"], + Proxy: proxy, + Stale: strings.EqualFold(directives["stale"], "true"), + } + if challenge.Realm == "" || challenge.Nonce == "" { + return digestChallenge{}, errors.New("ims: incomplete SIP digest challenge") + } + if challenge.Algorithm == "" { + // RFC 3310 inherits the HTTP Digest default: an omitted algorithm is + // plain MD5, not AKA. This provider has no subscriber password and + // must not misinterpret an ordinary nonce as RAND || AUTN. + return digestChallenge{}, errors.New("ims: digest challenge omitted the AKA algorithm") + } + if !strings.EqualFold(challenge.Algorithm, "AKAv1-MD5") { + return digestChallenge{}, fmt.Errorf("ims: unsupported digest algorithm %q", challenge.Algorithm) + } + if qop := directives["qop"]; qop != "" { + for _, candidate := range strings.Split(qop, ",") { + if strings.EqualFold(strings.TrimSpace(candidate), "auth") { + challenge.QOP = "auth" + break + } + } + if challenge.QOP == "" { + return digestChallenge{}, errors.New("ims: digest challenge does not offer qop=auth") + } + } + return challenge, nil +} + +func parseAuthDirectives(value string) (map[string]string, error) { + directives := make(map[string]string) + for index := 0; index < len(value); { + for index < len(value) && (value[index] == ' ' || value[index] == '\t' || value[index] == ',') { + index++ + } + if index == len(value) { + break + } + keyStart := index + for index < len(value) && value[index] != '=' && value[index] != ',' { + index++ + } + if index == len(value) || value[index] != '=' { + return nil, errors.New("ims: malformed digest directive") + } + key := strings.ToLower(strings.TrimSpace(value[keyStart:index])) + index++ + for index < len(value) && (value[index] == ' ' || value[index] == '\t') { + index++ + } + var directiveValue strings.Builder + if index < len(value) && value[index] == '"' { + index++ + closed := false + for index < len(value) { + switch value[index] { + case '\\': + index++ + if index == len(value) { + return nil, errors.New("ims: malformed quoted digest directive") + } + directiveValue.WriteByte(value[index]) + index++ + case '"': + index++ + closed = true + default: + directiveValue.WriteByte(value[index]) + index++ + } + if closed { + break + } + } + if !closed { + return nil, errors.New("ims: unterminated quoted digest directive") + } + } else { + start := index + for index < len(value) && value[index] != ',' { + index++ + } + directiveValue.WriteString(strings.TrimSpace(value[start:index])) + } + if key == "" { + return nil, errors.New("ims: empty digest directive name") + } + directives[key] = directiveValue.String() + for index < len(value) && value[index] != ',' { + if value[index] != ' ' && value[index] != '\t' { + return nil, errors.New("ims: malformed digest directive separator") + } + index++ + } + } + return directives, nil +} + +type akaMaterial struct { + password []byte + auts []byte + ck []byte + ik []byte +} + +func clearAKAMaterial(material *akaMaterial) { + if material == nil { + return + } + zeroBytes(material.password) + zeroBytes(material.auts) + zeroBytes(material.ck) + zeroBytes(material.ik) + *material = akaMaterial{} +} + +func authenticateAKA( + ctx context.Context, + provider vowifi.AKAProvider, + identity vowifi.SIMIdentity, + challenge digestChallenge, +) (akaMaterial, error) { + nonce, err := decodeAKANonce(challenge.Nonce) + if err != nil { + return akaMaterial{}, err + } + // 3GPP HTTP Digest AKA encodes RAND || AUTN as the first 32 nonce octets. + // Following server data remains in the digest nonce and never enters USIM. + var akaChallenge vowifi.AKAChallenge + copy(akaChallenge.RAND[:], nonce[:16]) + copy(akaChallenge.AUTN[:], nonce[16:32]) + result, err := provider.Authenticate(ctx, identity, akaChallenge) + if err != nil { + return akaMaterial{}, fmt.Errorf("ims: USIM AKA authentication failed: %w", err) + } + if result.SynchronizationFailure || len(result.AUTS) > 0 { + if !result.SynchronizationFailure || len(result.AUTS) != 14 { + return akaMaterial{}, errors.New("ims: USIM returned malformed AKA synchronization evidence") + } + return akaMaterial{auts: append([]byte(nil), result.AUTS...)}, nil + } + res, err := extractRES(result) + if err != nil { + return akaMaterial{}, err + } + return akaMaterial{ + password: res, + ck: append([]byte(nil), result.CK...), + ik: append([]byte(nil), result.IK...), + }, nil +} + +func decodeAKANonce(value string) ([]byte, error) { + var decoded []byte + var err error + for _, encoding := range []*base64.Encoding{ + base64.StdEncoding, + base64.RawStdEncoding, + base64.URLEncoding, + base64.RawURLEncoding, + } { + decoded, err = encoding.DecodeString(strings.TrimSpace(value)) + if err == nil { + break + } + } + if err != nil || len(decoded) < 32 { + return nil, errors.New("ims: invalid AKA nonce") + } + return decoded, nil +} + +func extractRES(result vowifi.AKAResult) ([]byte, error) { + if len(result.RES) == 0 { + return nil, errors.New("ims: USIM returned an empty AKA result") + } + if len(result.RES) < 4 || len(result.RES) > 16 { + return nil, errors.New("ims: USIM returned an invalid RES length") + } + return append([]byte(nil), result.RES...), nil +} + +func newDigestCredentials( + username string, + password []byte, + uri string, + method string, + nc uint32, +) (digestCredentials, error) { + cnonceBytes := make([]byte, 16) + if _, err := rand.Read(cnonceBytes); err != nil { + return digestCredentials{}, fmt.Errorf("ims: create digest cnonce: %w", err) + } + return digestCredentials{ + Username: username, + Password: password, + URI: uri, + Method: method, + CNonce: hex.EncodeToString(cnonceBytes), + NC: nc, + }, nil +} + +func buildDigestAuthorization(challenge digestChallenge, credentials digestCredentials) string { + nc := fmt.Sprintf("%08x", credentials.NC) + response := digestResponse( + credentials.Username, + challenge.Realm, + credentials.Password, + credentials.Method, + credentials.URI, + challenge.Nonce, + nc, + credentials.CNonce, + challenge.QOP, + ) + parts := []string{ + `username="` + quoteDigest(credentials.Username) + `"`, + `realm="` + quoteDigest(challenge.Realm) + `"`, + `nonce="` + quoteDigest(challenge.Nonce) + `"`, + `uri="` + quoteDigest(credentials.URI) + `"`, + `response="` + response + `"`, + "algorithm=AKAv1-MD5", + } + if challenge.Opaque != "" { + parts = append(parts, `opaque="`+quoteDigest(challenge.Opaque)+`"`) + } + if challenge.QOP != "" { + parts = append(parts, + "qop="+challenge.QOP, + "nc="+nc, + `cnonce="`+quoteDigest(credentials.CNonce)+`"`, + ) + } + if credentials.AUTS != "" { + parts = append(parts, `auts="`+quoteDigest(credentials.AUTS)+`"`) + } + return "Digest " + strings.Join(parts, ", ") +} + +func digestResponse( + username string, + realm string, + password []byte, + method string, + uri string, + nonce string, + nc string, + cnonce string, + qop string, +) string { + ha1Hash := md5.New() + _, _ = ha1Hash.Write([]byte(username + ":" + realm + ":")) + _, _ = ha1Hash.Write(password) + ha1 := hex.EncodeToString(ha1Hash.Sum(nil)) + ha2 := md5Hex(method + ":" + uri) + if qop == "" { + return md5Hex(ha1 + ":" + nonce + ":" + ha2) + } + return md5Hex(ha1 + ":" + nonce + ":" + nc + ":" + cnonce + ":" + qop + ":" + ha2) +} + +func md5Hex(value string) string { + sum := md5.Sum([]byte(value)) + return hex.EncodeToString(sum[:]) +} + +func quoteDigest(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + return strings.ReplaceAll(value, `"`, `\"`) +} diff --git a/internal/vowifi/ims/digest_test.go b/internal/vowifi/ims/digest_test.go new file mode 100644 index 0000000..b9dd0da --- /dev/null +++ b/internal/vowifi/ims/digest_test.go @@ -0,0 +1,157 @@ +package ims + +import ( + "context" + "encoding/base64" + "reflect" + "strings" + "testing" + + "vocat/internal/vowifi" +) + +type recordingAKA struct { + result vowifi.AKAResult + err error + challenges []vowifi.AKAChallenge +} + +func (aka *recordingAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) { + return vowifi.AKAEvidence{Ready: true, Application: "usim"}, nil +} + +func (aka *recordingAKA) Authenticate( + _ context.Context, + _ vowifi.SIMIdentity, + challenge vowifi.AKAChallenge, +) (vowifi.AKAResult, error) { + aka.challenges = append(aka.challenges, challenge) + return aka.result, aka.err +} + +func TestDigestResponseRFC2617Vector(t *testing.T) { + got := digestResponse( + "Mufasa", + "testrealm@host.com", + []byte("Circle Of Life"), + "GET", + "/dir/index.html", + "dcd98b7102dd2f0e8b11d0f600bfb0c093", + "00000001", + "0a4f113b", + "auth", + ) + const want = "6629fae49393a05397450978507c4ef1" + if got != want { + t.Fatalf("digestResponse() = %q, want %q", got, want) + } +} + +func TestAuthenticateAKAMapsNonceToTypedChallenge(t *testing.T) { + nonceBytes := make([]byte, 40) + for index := range nonceBytes { + nonceBytes[index] = byte(index) + } + aka := &recordingAKA{ + result: vowifi.AKAResult{RES: []byte{0xde, 0xad, 0xbe, 0xef}}, + } + material, err := authenticateAKA( + context.Background(), + aka, + vowifi.SIMIdentity{IMSI: "001010123456789"}, + digestChallenge{Nonce: base64.StdEncoding.EncodeToString(nonceBytes)}, + ) + if err != nil { + t.Fatalf("authenticateAKA() error = %v", err) + } + if !reflect.DeepEqual(material.password, []byte{0xde, 0xad, 0xbe, 0xef}) { + t.Fatalf("password = %x, want deadbeef", material.password) + } + if len(aka.challenges) != 1 { + t.Fatalf("challenge count = %d, want 1", len(aka.challenges)) + } + var wantRAND, wantAUTN [16]byte + copy(wantRAND[:], nonceBytes[:16]) + copy(wantAUTN[:], nonceBytes[16:32]) + if !reflect.DeepEqual(aka.challenges[0].RAND, wantRAND) || + !reflect.DeepEqual(aka.challenges[0].AUTN, wantAUTN) { + t.Fatalf("typed challenge = %#v", aka.challenges[0]) + } +} + +func TestAuthenticateAKAReturnsSynchronizationEvidence(t *testing.T) { + nonce := base64.StdEncoding.EncodeToString(make([]byte, 32)) + auts := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13} + aka := &recordingAKA{ + result: vowifi.AKAResult{ + AUTS: auts, + SynchronizationFailure: true, + }, + } + material, err := authenticateAKA( + context.Background(), + aka, + vowifi.SIMIdentity{}, + digestChallenge{Nonce: nonce}, + ) + if err != nil { + t.Fatalf("authenticateAKA() error = %v", err) + } + if !reflect.DeepEqual(material.auts, auts) || len(material.password) != 0 { + t.Fatalf("material = %#v", material) + } +} + +func TestBuildDigestAuthorizationCarriesAUTSWithEmptyPassword(t *testing.T) { + authorization := buildDigestAuthorization( + digestChallenge{ + Realm: "ims.example", + Nonce: "nonce", + Algorithm: "AKAv1-MD5", + QOP: "auth", + }, + digestCredentials{ + Username: "private@ims.example", + Password: nil, + AUTS: "AAECAwQFBgcICQoLDA0=", + URI: "sip:ims.example", + Method: "REGISTER", + CNonce: "cnonce", + NC: 1, + }, + ) + directives, err := parseAuthDirectives(strings.TrimPrefix(authorization, "Digest ")) + if err != nil { + t.Fatalf("parseAuthDirectives() error = %v", err) + } + if directives["auts"] != "AAECAwQFBgcICQoLDA0=" { + t.Fatalf("AUTS = %q", directives["auts"]) + } + expected := digestResponse( + "private@ims.example", + "ims.example", + nil, + "REGISTER", + "sip:ims.example", + "nonce", + "00000001", + "cnonce", + "auth", + ) + if directives["response"] != expected { + t.Fatalf("response = %q, want %q", directives["response"], expected) + } +} + +func TestParseDigestChallengeSelectsAuth(t *testing.T) { + challenge, err := parseDigestChallenge( + `Digest realm="ims.example", nonce="abc", algorithm=AKAv1-MD5, qop="auth-int, auth", opaque="x\"y"`, + false, + ) + if err != nil { + t.Fatalf("parseDigestChallenge() error = %v", err) + } + if challenge.QOP != "auth" || challenge.Opaque != `x"y` { + t.Fatalf("challenge = %#v", challenge) + } +} diff --git a/internal/vowifi/ims/message.go b/internal/vowifi/ims/message.go new file mode 100644 index 0000000..8374a23 --- /dev/null +++ b/internal/vowifi/ims/message.go @@ -0,0 +1,284 @@ +package ims + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +const maxSIPHeaderBytes = 64 << 10 + +type sipResponse struct { + StatusCode int + Reason string + Headers map[string][]string + Body []byte +} + +type sipRequest struct { + Method string + URI string + Headers map[string][]string + Body []byte +} + +func (request *sipRequest) values(name string) []string { + if request == nil { + return nil + } + return append([]string(nil), request.Headers[strings.ToLower(strings.TrimSpace(name))]...) +} + +func (request *sipRequest) value(name string) string { + values := request.values(name) + if len(values) == 0 { + return "" + } + return values[0] +} + +type sipPacket struct { + Response *sipResponse + Request *sipRequest +} + +func (response *sipResponse) values(name string) []string { + if response == nil { + return nil + } + return append([]string(nil), response.Headers[strings.ToLower(strings.TrimSpace(name))]...) +} + +func (response *sipResponse) value(name string) string { + values := response.values(name) + if len(values) == 0 { + return "" + } + return values[0] +} + +func parseSIPResponse(packet []byte) (*sipResponse, error) { + parsed, err := parseSIPPacket(packet) + if err != nil { + return nil, err + } + if parsed.Response == nil { + return nil, errors.New("ims: SIP packet is not a response") + } + return parsed.Response, nil +} + +func parseSIPPacket(packet []byte) (sipPacket, error) { + headerEnd, delimiterSize := findHeaderEnd(packet) + if headerEnd < 0 { + return sipPacket{}, errors.New("ims: incomplete SIP headers") + } + if headerEnd > maxSIPHeaderBytes { + return sipPacket{}, errors.New("ims: SIP headers exceed limit") + } + parsed, contentLength, err := parseSIPHeaderBlockAny(packet[:headerEnd]) + if err != nil { + return sipPacket{}, err + } + body := packet[headerEnd+delimiterSize:] + if contentLength > len(body) { + return sipPacket{}, errors.New("ims: incomplete SIP body") + } + if parsed.Response != nil { + parsed.Response.Body = append([]byte(nil), body[:contentLength]...) + } else { + parsed.Request.Body = append([]byte(nil), body[:contentLength]...) + } + return parsed, nil +} + +func readSIPResponse(reader *bufio.Reader) (*sipResponse, error) { + packet, err := readSIPPacket(reader) + if err != nil { + return nil, err + } + if packet.Response == nil { + return nil, errors.New("ims: SIP packet is not a response") + } + return packet.Response, nil +} + +func readSIPPacket(reader *bufio.Reader) (sipPacket, error) { + var header bytes.Buffer + for header.Len() <= maxSIPHeaderBytes { + line, err := reader.ReadString('\n') + if err != nil { + return sipPacket{}, fmt.Errorf("ims: read SIP headers: %w", err) + } + header.WriteString(line) + if line == "\r\n" || line == "\n" { + headerBytes := header.Bytes() + headerEnd, _ := findHeaderEnd(headerBytes) + if headerEnd < 0 { + return sipPacket{}, errors.New("ims: incomplete SIP headers") + } + packet, contentLength, parseErr := parseSIPHeaderBlockAny(headerBytes[:headerEnd]) + if parseErr != nil { + return sipPacket{}, parseErr + } + if contentLength > 0 { + body := make([]byte, contentLength) + if _, err := io.ReadFull(reader, body); err != nil { + return sipPacket{}, fmt.Errorf("ims: read SIP body: %w", err) + } + if packet.Response != nil { + packet.Response.Body = body + } else { + packet.Request.Body = body + } + } + return packet, nil + } + } + return sipPacket{}, errors.New("ims: SIP headers exceed limit") +} + +func parseSIPHeaderBlock(block []byte) (*sipResponse, int, error) { + packet, length, err := parseSIPHeaderBlockAny(block) + if err != nil { + return nil, 0, err + } + if packet.Response == nil { + return nil, 0, errors.New("ims: SIP packet is not a response") + } + return packet.Response, length, nil +} + +func parseSIPHeaderBlockAny(block []byte) (sipPacket, int, error) { + text := strings.ReplaceAll(string(block), "\r\n", "\n") + rawLines := strings.Split(text, "\n") + if len(rawLines) == 0 { + return sipPacket{}, 0, errors.New("ims: empty SIP message") + } + startFields := strings.Fields(strings.TrimSpace(rawLines[0])) + if len(startFields) < 2 { + return sipPacket{}, 0, errors.New("ims: invalid SIP start line") + } + + lines := make([]string, 0, len(rawLines)-1) + for _, raw := range rawLines[1:] { + if raw == "" { + continue + } + if (strings.HasPrefix(raw, " ") || strings.HasPrefix(raw, "\t")) && len(lines) > 0 { + lines[len(lines)-1] += " " + strings.TrimSpace(raw) + continue + } + lines = append(lines, raw) + } + + headers := make(map[string][]string) + for _, line := range lines { + colon := strings.IndexByte(line, ':') + if colon <= 0 { + return sipPacket{}, 0, errors.New("ims: malformed SIP header") + } + name := strings.ToLower(strings.TrimSpace(line[:colon])) + if name == "" { + return sipPacket{}, 0, errors.New("ims: empty SIP header name") + } + // RFC 3261 compact forms commonly appear on bandwidth-sensitive IMS + // links. Canonicalize the fields used by transactions and MESSAGE. + if canonical, ok := map[string]string{ + "v": "via", "f": "from", "t": "to", "i": "call-id", + "l": "content-length", "c": "content-type", + }[name]; ok { + name = canonical + } + value := strings.TrimSpace(line[colon+1:]) + headers[name] = append(headers[name], value) + } + + contentLength := 0 + var err error + if values := headers["content-length"]; len(values) > 0 { + contentLength, err = strconv.Atoi(strings.TrimSpace(values[len(values)-1])) + if err != nil || contentLength < 0 || contentLength > 1<<20 { + return sipPacket{}, 0, errors.New("ims: invalid SIP Content-Length") + } + } + if strings.EqualFold(startFields[0], "SIP/2.0") { + statusCode, parseErr := strconv.Atoi(startFields[1]) + if parseErr != nil || statusCode < 100 || statusCode > 699 { + return sipPacket{}, 0, errors.New("ims: invalid SIP response status code") + } + reason := "" + if len(startFields) > 2 { + reason = strings.Join(startFields[2:], " ") + } + return sipPacket{Response: &sipResponse{StatusCode: statusCode, Reason: reason, Headers: headers}}, contentLength, nil + } + if len(startFields) != 3 || !strings.EqualFold(startFields[2], "SIP/2.0") || + startFields[0] == "" || startFields[1] == "" { + return sipPacket{}, 0, errors.New("ims: invalid SIP request line") + } + return sipPacket{Request: &sipRequest{ + Method: strings.ToUpper(startFields[0]), + URI: startFields[1], + Headers: headers, + }}, contentLength, nil +} + +func findHeaderEnd(packet []byte) (index int, delimiterSize int) { + if index := bytes.Index(packet, []byte("\r\n\r\n")); index >= 0 { + return index, 4 + } + if index := bytes.Index(packet, []byte("\n\n")); index >= 0 { + return index, 2 + } + return -1, 0 +} + +func splitHeaderValues(values []string) []string { + var result []string + for _, value := range values { + start := 0 + quoted := false + escaped := false + angleDepth := 0 + for index, character := range value { + switch { + case escaped: + escaped = false + case quoted && character == '\\': + escaped = true + case character == '"': + quoted = !quoted + case !quoted && character == '<': + angleDepth++ + case !quoted && character == '>' && angleDepth > 0: + angleDepth-- + case !quoted && angleDepth == 0 && character == ',': + if item := strings.TrimSpace(value[start:index]); item != "" { + result = append(result, item) + } + start = index + 1 + } + } + if item := strings.TrimSpace(value[start:]); item != "" { + result = append(result, item) + } + } + return result +} + +func cseqNumber(value string) (uint32, string, error) { + fields := strings.Fields(value) + if len(fields) != 2 { + return 0, "", errors.New("ims: malformed CSeq") + } + number, err := strconv.ParseUint(fields[0], 10, 32) + if err != nil { + return 0, "", errors.New("ims: malformed CSeq number") + } + return uint32(number), strings.ToUpper(fields[1]), nil +} diff --git a/internal/vowifi/ims/message_test.go b/internal/vowifi/ims/message_test.go new file mode 100644 index 0000000..c2d7de9 --- /dev/null +++ b/internal/vowifi/ims/message_test.go @@ -0,0 +1,81 @@ +package ims + +import ( + "reflect" + "testing" +) + +func TestParseSIPResponseAndSplitIdentityHeaders(t *testing.T) { + packet := []byte( + "SIP/2.0 200 OK\r\n" + + "Call-ID: register@example\r\n" + + "CSeq: 2 REGISTER\r\n" + + "P-Associated-URI: ,\r\n" + + " \r\n" + + "Service-Route: \r\n" + + "Service-Route: \r\n" + + "Content-Length: 4\r\n\r\nbody", + ) + response, err := parseSIPResponse(packet) + if err != nil { + t.Fatalf("parseSIPResponse() error = %v", err) + } + if response.StatusCode != 200 || string(response.Body) != "body" { + t.Fatalf("unexpected response: %#v", response) + } + identities := splitHeaderValues(response.values("P-Associated-URI")) + wantIdentities := []string{ + "", + "", + } + if !reflect.DeepEqual(identities, wantIdentities) { + t.Fatalf("identities = %#v, want %#v", identities, wantIdentities) + } + wantRoutes := []string{"", ""} + if routes := splitHeaderValues(response.values("Service-Route")); !reflect.DeepEqual(routes, wantRoutes) { + t.Fatalf("routes = %#v, want %#v", routes, wantRoutes) + } +} + +func TestParseSIPResponseRejectsIncompleteBody(t *testing.T) { + _, err := parseSIPResponse([]byte("SIP/2.0 200 OK\r\nContent-Length: 4\r\n\r\nx")) + if err == nil { + t.Fatal("parseSIPResponse() error = nil, want incomplete body error") + } +} + +func TestParseSIPMessageRequestWithBinaryBody(t *testing.T) { + body := []byte{0x01, 0x2a, 0x00, 0x00} + packet := append([]byte( + "MESSAGE sip:user@example.test SIP/2.0\r\n"+ + "Via: SIP/2.0/TCP proxy.example.test;branch=z9hG4bK1\r\n"+ + "From: ;tag=a\r\n"+ + "To: \r\n"+ + "Call-ID: inbound-1\r\n"+ + "CSeq: 1 MESSAGE\r\n"+ + "Content-Type: application/vnd.3gpp.sms\r\n"+ + "Content-Length: 4\r\n\r\n", + ), body...) + message, err := parseSIPPacket(packet) + if err != nil { + t.Fatalf("parseSIPPacket: %v", err) + } + if message.Request == nil || message.Request.Method != "MESSAGE" || + message.Request.URI != "sip:user@example.test" || + !reflect.DeepEqual(message.Request.Body, body) { + t.Fatalf("message = %#v", message) + } +} + +func TestSplitHeaderValuesPreservesQuotedCommas(t *testing.T) { + values := splitHeaderValues([]string{ + `"Doe, Jane" , `, + }) + want := []string{ + `"Doe, Jane" `, + ``, + } + if !reflect.DeepEqual(values, want) { + t.Fatalf("splitHeaderValues() = %#v, want %#v", values, want) + } +} diff --git a/internal/vowifi/ims/provider.go b/internal/vowifi/ims/provider.go new file mode 100644 index 0000000..3705f2d --- /dev/null +++ b/internal/vowifi/ims/provider.go @@ -0,0 +1,1228 @@ +package ims + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync" + "time" + + "vocat/internal/vowifi" +) + +const ( + defaultSIPPort = 5060 + defaultRegistrationExpiry = 3600 * time.Second + defaultTransactionTimeout = 12 * time.Second + maxAuthenticationChallenges = 3 +) + +var ( + ErrRegistrationRejected = errors.New("ims: SIP registration was rejected") + ErrSessionClosed = errors.New("ims: session is closed") + ErrRegistrationExpired = errors.New("ims: registration has expired") + ErrSMSCapabilityNotConfirmed = errors.New("ims: registrar did not confirm the +g.3gpp.smsip contact") +) + +// Config defines only deployment-specific SIP details. If PCSCF or +// LocalAddress is empty, Provider uses the corresponding value proven by the +// TunnelSession. The default transport is TCP and the default port is 5060. +type Config struct { + PCSCF string + LocalAddress string + Transport string + Port int + RegistrationExpiry time.Duration + TransactionTimeout time.Duration + PrivateIdentity string + PublicIdentity string + UserAgent string + SecurityMode SecurityMode + IPSecInstaller IPSecSAInstaller + ProtectedClientPort int + ProtectedServerPort int + // SMSCenter is an operator-provided fallback when the SIM leaves EF_SMSP + // and AT+CSCA empty. It must be an international or national digit string. + SMSCenter string + // OnSMS is invoked after a valid inbound RP-DATA/SMS-DELIVER has been + // decoded. Returning an error causes an RP-ERROR delivery report. + OnSMS func(context.Context, ReceivedSMS) error + // OnSMSStatus is invoked for an SMS-STATUS-REPORT received after a + // submission that requested a delivery report. + OnSMSStatus func(context.Context, ReceivedSMSStatus) error +} + +// Provider implements vowifi.IMSProvider using a small RFC 3261 REGISTER +// transaction and 3GPP AKAv1-MD5 authentication. It has no SIP stack or +// runtime dependency outside the Go standard library. +type Provider struct { + aka vowifi.AKAProvider + config Config + installer IPSecSAInstaller +} + +func NewProvider(aka vowifi.AKAProvider, config Config) (*Provider, error) { + if aka == nil { + return nil, errors.New("ims: AKA provider is required") + } + normalized, err := normalizeConfig(config) + if err != nil { + return nil, err + } + installer := normalized.IPSecInstaller + if installer == nil { + installer = defaultIPSecInstaller() + } + return &Provider{aka: aka, config: normalized, installer: installer}, nil +} + +func normalizeConfig(config Config) (Config, error) { + if config.Port == 0 { + config.Port = defaultSIPPort + } + if config.Port < 1 || config.Port > 65535 { + return Config{}, errors.New("ims: SIP port is out of range") + } + if config.RegistrationExpiry == 0 { + config.RegistrationExpiry = defaultRegistrationExpiry + } + if config.RegistrationExpiry < time.Minute || config.RegistrationExpiry > 24*time.Hour { + return Config{}, errors.New("ims: registration expiry must be between one minute and 24 hours") + } + if config.TransactionTimeout == 0 { + config.TransactionTimeout = defaultTransactionTimeout + } + if config.TransactionTimeout < time.Second || config.TransactionTimeout > time.Minute { + return Config{}, errors.New("ims: transaction timeout must be between one second and one minute") + } + config.Transport = strings.ToLower(strings.TrimSpace(config.Transport)) + if config.Transport != "" && config.Transport != "udp" && config.Transport != "tcp" { + return Config{}, fmt.Errorf("ims: unsupported SIP transport %q", config.Transport) + } + if strings.TrimSpace(config.UserAgent) == "" { + config.UserAgent = "vocat/1" + } + if config.SecurityMode == "" { + config.SecurityMode = SecurityRequired + } + switch config.SecurityMode { + case SecurityRequired, SecurityOptional, SecurityDisabled: + default: + return Config{}, fmt.Errorf("ims: unsupported security mode %q", config.SecurityMode) + } + if config.ProtectedClientPort != 0 && !validProtectedPort(config.ProtectedClientPort) { + return Config{}, errors.New("ims: protected client port is invalid") + } + if config.ProtectedServerPort != 0 && !validProtectedPort(config.ProtectedServerPort) { + return Config{}, errors.New("ims: protected server port is invalid") + } + if config.ProtectedClientPort != 0 && + config.ProtectedClientPort == config.ProtectedServerPort { + return Config{}, errors.New("ims: protected client and server ports must differ") + } + for name, value := range map[string]string{ + "PCSCF": config.PCSCF, "local address": config.LocalAddress, + "private identity": config.PrivateIdentity, "public identity": config.PublicIdentity, + "user agent": config.UserAgent, + } { + if strings.ContainsAny(value, "\r\n") { + return Config{}, fmt.Errorf("ims: %s contains a line break", name) + } + } + config.PCSCF = strings.TrimSpace(config.PCSCF) + config.LocalAddress = strings.TrimSpace(config.LocalAddress) + config.PrivateIdentity = strings.TrimSpace(config.PrivateIdentity) + config.PublicIdentity = strings.TrimSpace(config.PublicIdentity) + config.UserAgent = strings.TrimSpace(config.UserAgent) + config.SMSCenter = strings.TrimSpace(config.SMSCenter) + if config.SMSCenter != "" { + digits := strings.TrimPrefix(config.SMSCenter, "+") + if !digitsBetween(digits, 3, 20) { + return Config{}, errors.New("ims: configured SMS service-centre address is invalid") + } + } + return config, nil +} + +func (provider *Provider) Start(ctx context.Context, request vowifi.IMSRequest) (vowifi.IMSSession, error) { + if ctx == nil { + ctx = context.Background() + } + if request.Tunnel == nil { + return nil, errors.New("ims: tunnel session is required") + } + tunnel := request.Tunnel.Evidence() + if !tunnel.Established { + return nil, vowifi.ErrTunnelNotEstablished + } + identities, err := deriveIdentities(request.Identity, provider.config) + if err != nil { + return nil, err + } + pcscf := provider.config.PCSCF + if pcscf == "" { + for _, candidate := range tunnel.PCSCF { + if strings.TrimSpace(candidate) != "" { + pcscf = candidate + break + } + } + } + if pcscf == "" { + return nil, errors.New("ims: tunnel did not provide a P-CSCF") + } + endpoint, transportHint, err := parsePCSCF(pcscf, provider.config.Port) + if err != nil { + return nil, err + } + if provider.config.PCSCF != "" && !pcscfProvenByTunnel(endpoint, tunnel.PCSCF, provider.config.Port) { + return nil, errors.New("ims: configured P-CSCF is not proven by the SWu tunnel") + } + transport := provider.config.Transport + if transport == "" { + transport = transportHint + } + if transport == "" { + transport = "tcp" + } + localAddress := provider.config.LocalAddress + if localAddress == "" { + if endpointIP := net.ParseIP(endpoint.host); endpointIP != nil && endpointIP.To4() == nil { + localAddress = tunnel.LocalIPv6 + } else { + localAddress = tunnel.LocalIPv4 + if strings.TrimSpace(localAddress) == "" { + localAddress = tunnel.LocalIPv6 + } + } + } + localAddress = strings.TrimSpace(strings.Split(localAddress, "/")[0]) + if localAddress == "" { + return nil, errors.New("ims: tunnel did not provide a local address") + } + if !localAddressProvenByTunnel(localAddress, tunnel) { + return nil, errors.New("ims: configured local address is not assigned by the SWu tunnel") + } + + connection, err := dialSIP(ctx, transport, localAddress, 0, endpoint.address()) + if err != nil { + return nil, fmt.Errorf("ims: connect to P-CSCF: %w", err) + } + session, err := newSession(provider, request, identities, endpoint, transport, connection) + if err != nil { + _ = connection.Close() + return nil, err + } + if err := session.establish(ctx); err != nil { + session.abort() + return nil, err + } + return session, nil +} + +type identitySet struct { + domain string + private string + public string + user string +} + +func deriveIdentities(identity vowifi.SIMIdentity, config Config) (identitySet, error) { + imsi := strings.TrimSpace(identity.IMSI) + if !digitsBetween(imsi, 5, 16) { + return identitySet{}, errors.New("ims: SIM IMSI is unavailable or invalid") + } + mcc := strings.TrimSpace(identity.HomeMCC) + mnc := strings.TrimSpace(identity.HomeMNC) + if !digitsBetween(mcc, 3, 3) || !digitsBetween(mnc, 2, 3) { + return identitySet{}, errors.New("ims: home PLMN is unavailable or invalid") + } + for len(mnc) < 3 { + mnc = "0" + mnc + } + domain := fmt.Sprintf("ims.mnc%s.mcc%s.3gppnetwork.org", mnc, mcc) + privateIdentity := config.PrivateIdentity + if privateIdentity == "" { + privateIdentity = imsi + "@" + domain + } + publicIdentity := config.PublicIdentity + if publicIdentity == "" { + publicIdentity = "sip:" + imsi + "@" + domain + } + if strings.ContainsAny(privateIdentity+publicIdentity, "\r\n") || + !strings.Contains(privateIdentity, "@") || + (!strings.HasPrefix(strings.ToLower(publicIdentity), "sip:") && + !strings.HasPrefix(strings.ToLower(publicIdentity), "sips:")) { + return identitySet{}, errors.New("ims: configured IMS identity is invalid") + } + user := strings.TrimPrefix(strings.TrimPrefix(publicIdentity, "sip:"), "sips:") + if at := strings.IndexByte(user, '@'); at >= 0 { + user = user[:at] + } + if user == "" || strings.ContainsAny(user, "<>\" \t;") { + return identitySet{}, errors.New("ims: public identity user is invalid") + } + return identitySet{domain: domain, private: privateIdentity, public: publicIdentity, user: user}, nil +} + +type pcscfEndpoint struct { + host string + port int +} + +func (endpoint pcscfEndpoint) address() string { + return net.JoinHostPort(endpoint.host, strconv.Itoa(endpoint.port)) +} + +func parsePCSCF(raw string, defaultPort int) (pcscfEndpoint, string, error) { + value := strings.Trim(strings.TrimSpace(raw), "<>") + lower := strings.ToLower(value) + if strings.HasPrefix(lower, "sip:") { + value = value[4:] + } else if strings.HasPrefix(lower, "sips:") { + value = value[5:] + } + transport := "" + if separator := strings.IndexAny(value, ";?"); separator >= 0 { + parameters := value[separator+1:] + value = value[:separator] + for _, parameter := range strings.FieldsFunc(parameters, func(character rune) bool { + return character == ';' || character == '&' + }) { + key, parameterValue, found := strings.Cut(parameter, "=") + if found && strings.EqualFold(strings.TrimSpace(key), "transport") { + transport = strings.ToLower(strings.TrimSpace(parameterValue)) + } + } + } + if at := strings.LastIndexByte(value, '@'); at >= 0 { + value = value[at+1:] + } + value = strings.TrimSpace(value) + if value == "" || strings.ContainsAny(value, "\r\n/") { + return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF address") + } + + host := value + port := defaultPort + if parsedHost, parsedPort, err := net.SplitHostPort(value); err == nil { + host = parsedHost + numericPort, parseErr := strconv.Atoi(parsedPort) + if parseErr != nil || numericPort < 1 || numericPort > 65535 { + return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF port") + } + port = numericPort + } else if strings.HasPrefix(value, "[") && strings.HasSuffix(value, "]") { + host = strings.Trim(value, "[]") + } else if strings.Count(value, ":") == 1 { + candidateHost, candidatePort, found := strings.Cut(value, ":") + if found { + numericPort, parseErr := strconv.Atoi(candidatePort) + if parseErr != nil || numericPort < 1 || numericPort > 65535 { + return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF port") + } + host = candidateHost + port = numericPort + } + } + host = strings.Trim(strings.TrimSpace(host), "[]") + if host == "" || strings.ContainsAny(host, " \t<>\"") { + return pcscfEndpoint{}, "", errors.New("ims: invalid P-CSCF host") + } + if transport != "" && transport != "udp" && transport != "tcp" { + return pcscfEndpoint{}, "", fmt.Errorf("ims: unsupported P-CSCF transport %q", transport) + } + return pcscfEndpoint{host: host, port: port}, transport, nil +} + +func pcscfProvenByTunnel(endpoint pcscfEndpoint, candidates []string, defaultPort int) bool { + for _, candidate := range candidates { + proven, _, err := parsePCSCF(candidate, defaultPort) + if err != nil { + continue + } + if proven.port == endpoint.port && equalHost(proven.host, endpoint.host) { + return true + } + } + return false +} + +func equalHost(left string, right string) bool { + leftIP := net.ParseIP(strings.Trim(left, "[]")) + rightIP := net.ParseIP(strings.Trim(right, "[]")) + if leftIP != nil || rightIP != nil { + return leftIP != nil && rightIP != nil && leftIP.Equal(rightIP) + } + return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) +} + +func localAddressProvenByTunnel(localAddress string, tunnel vowifi.TunnelEvidence) bool { + localIP := net.ParseIP(strings.Trim(localAddress, "[]")) + if localIP == nil { + return false + } + for _, candidate := range []string{tunnel.LocalIPv4, tunnel.LocalIPv6} { + candidate = strings.TrimSpace(strings.Split(candidate, "/")[0]) + candidateIP := net.ParseIP(strings.Trim(candidate, "[]")) + if candidateIP != nil && localIP.Equal(candidateIP) { + return true + } + } + return false +} + +func dialSIP( + ctx context.Context, + transport string, + localAddress string, + localPort int, + remoteAddress string, +) (net.Conn, error) { + var local net.Addr + var err error + switch transport { + case "udp": + local, err = net.ResolveUDPAddr("udp", net.JoinHostPort(localAddress, strconv.Itoa(localPort))) + case "tcp": + local, err = net.ResolveTCPAddr("tcp", net.JoinHostPort(localAddress, strconv.Itoa(localPort))) + default: + return nil, fmt.Errorf("ims: unsupported SIP transport %q", transport) + } + if err != nil { + return nil, fmt.Errorf("resolve tunnel local address: %w", err) + } + dialer := net.Dialer{LocalAddr: local} + return dialer.DialContext(ctx, transport, remoteAddress) +} + +type authenticationState struct { + challenge digestChallenge + password []byte + auts string + cnonce string + nc uint32 +} + +type Session struct { + provider *Provider + request vowifi.IMSRequest + identity identitySet + endpoint pcscfEndpoint + transport string + conn net.Conn + reader *bufio.Reader + initialEndpoint pcscfEndpoint + securityDeclined bool + + callID string + fromTag string + instanceID string + cseq uint32 + auth *authenticationState + securityProposal securityProposal + securityAgreement securityAgreement + securityActive bool + ipsecHandle IPSecSAHandle + protectedTCP *net.TCPListener + protectedUDP *net.UDPConn + failures chan error + failureOnce sync.Once + writeMu sync.Mutex + transactionsMu sync.Mutex + transactions map[sipTransactionKey]chan *sipResponse + runtimeStarted bool + receiveDone sync.WaitGroup + inboundMu sync.Mutex + inboundConnections map[net.Conn]struct{} + smsMu sync.Mutex + nextRPReference byte + + mu sync.Mutex + closed bool + evidence vowifi.IMSEvidence + smsContactConfirmed bool + expiresAt time.Time + refreshContext context.Context + refreshCancel context.CancelFunc + refreshDone chan struct{} +} + +func newSession( + provider *Provider, + request vowifi.IMSRequest, + identity identitySet, + endpoint pcscfEndpoint, + transport string, + connection net.Conn, +) (*Session, error) { + callToken, err := randomHex(18) + if err != nil { + return nil, err + } + fromTag, err := randomHex(8) + if err != nil { + return nil, err + } + instanceID, err := randomUUID() + if err != nil { + return nil, err + } + refreshContext, refreshCancel := context.WithCancel(context.Background()) + session := &Session{ + provider: provider, + request: request, + identity: identity, + endpoint: endpoint, + initialEndpoint: endpoint, + transport: transport, + conn: connection, + callID: callToken + "@" + addressHost(connection.LocalAddr()), + fromTag: fromTag, + instanceID: "urn:uuid:" + instanceID, + cseq: 1, + refreshContext: refreshContext, + refreshCancel: refreshCancel, + refreshDone: make(chan struct{}), + failures: make(chan error, 1), + transactions: make(map[sipTransactionKey]chan *sipResponse), + inboundConnections: make(map[net.Conn]struct{}), + evidence: vowifi.IMSEvidence{ + RegistrationState: "registering", + Transport: transport, + }, + } + if transport == "tcp" { + session.reader = bufio.NewReader(connection) + } + if provider.config.SecurityMode != SecurityDisabled { + localIP := addressIP(connection.LocalAddr()) + if localIP == nil { + refreshCancel() + return nil, errors.New("ims: protected local IP address is unavailable") + } + proposal, err := newSecurityProposal( + localIP, + provider.config.ProtectedClientPort, + provider.config.ProtectedServerPort, + ) + if err != nil { + refreshCancel() + return nil, err + } + session.securityProposal = proposal + protectedTCP, err := net.ListenTCP( + "tcp", + &net.TCPAddr{IP: append(net.IP(nil), localIP...), Port: proposal.portServer}, + ) + if err != nil { + refreshCancel() + return nil, fmt.Errorf("ims: reserve protected TCP server port: %w", err) + } + protectedUDP, err := net.ListenUDP( + "udp", + &net.UDPAddr{IP: append(net.IP(nil), localIP...), Port: proposal.portServer}, + ) + if err != nil { + _ = protectedTCP.Close() + refreshCancel() + return nil, fmt.Errorf("ims: reserve protected UDP server port: %w", err) + } + session.protectedTCP = protectedTCP + session.protectedUDP = protectedUDP + } + return session, nil +} + +func (session *Session) abort() { + session.refreshCancel() + _ = session.conn.Close() + if session.protectedTCP != nil { + _ = session.protectedTCP.Close() + } + if session.protectedUDP != nil { + _ = session.protectedUDP.Close() + } + if session.ipsecHandle != nil { + _ = session.ipsecHandle.Close(context.Background()) + } + session.clearAuthentication() +} + +func (session *Session) establish(ctx context.Context) error { + session.mu.Lock() + defer session.mu.Unlock() + response, err := session.register(ctx, int(session.provider.config.RegistrationExpiry/time.Second)) + if err != nil { + session.evidence.RegistrationState = "failed" + return err + } + if response.StatusCode != 200 { + session.evidence.RegistrationState = "rejected" + phase := "initial" + if session.auth != nil { + phase = "authenticated" + } + return registrationRejectionError(response, phase) + } + if err := session.applyRegistrationEvidence(response); err != nil { + return err + } + if err := session.startRuntimeReceivers(); err != nil { + return err + } + go session.refreshLoop() + return nil +} + +// registrationRejectionError preserves the registrar's safe diagnostic text. +// Operators commonly use the SIP reason phrase, Reason, or Warning header to +// distinguish an unprovisioned subscriber from a malformed REGISTER. Do not +// include authentication headers here because they contain AKA material. +func registrationRejectionError(response *sipResponse, phase string) error { + if response == nil { + return fmt.Errorf("%w: empty SIP response", ErrRegistrationRejected) + } + phase = strings.TrimSpace(phase) + if phase == "" { + phase = "unknown" + } + message := fmt.Sprintf( + "%s: SIP %d", + phase+" REGISTER was rejected", + response.StatusCode, + ) + if reason := safeSIPDiagnostic(response.Reason); reason != "" { + message += " " + reason + } + for _, header := range []string{"Reason", "Warning"} { + for _, value := range response.values(header) { + if value = safeSIPDiagnostic(value); value != "" { + message += fmt.Sprintf("; %s: %s", header, value) + } + } + } + return fmt.Errorf("%w: %s", ErrRegistrationRejected, message) +} + +func safeSIPDiagnostic(value string) string { + value = strings.Join(strings.Fields(value), " ") + const maximum = 256 + if len(value) > maximum { + value = value[:maximum] + "..." + } + return value +} + +func (session *Session) register(ctx context.Context, expires int) (*sipResponse, error) { + for challenges := 0; challenges <= maxAuthenticationChallenges; challenges++ { + cseq := session.cseq + session.cseq++ + authorization := "" + authorizationHeader := "" + if session.auth != nil { + session.auth.nc++ + credentials := digestCredentials{ + Username: session.identity.private, + Password: session.auth.password, + AUTS: session.auth.auts, + URI: "sip:" + session.identity.domain, + Method: "REGISTER", + CNonce: session.auth.cnonce, + NC: session.auth.nc, + } + authorization = buildDigestAuthorization(session.auth.challenge, credentials) + if session.auth.challenge.Proxy { + authorizationHeader = "Proxy-Authorization" + } else { + authorizationHeader = "Authorization" + } + } + request, err := session.buildRegister(cseq, expires, authorizationHeader, authorization) + if err != nil { + return nil, err + } + response, err := session.exchange(ctx, request, cseq) + if err != nil { + return nil, err + } + session.evidence.LastSIPCode = response.StatusCode + if response.StatusCode != 401 && response.StatusCode != 407 { + return response, nil + } + if challenges == maxAuthenticationChallenges { + break + } + if session.securityActive { + return nil, errors.New("ims: protected registration was challenged again") + } + challenge, err := challengeFromResponse(response) + if err != nil { + return nil, err + } + agreement, useSecurity, err := session.securityFromResponse(response) + if err != nil { + return nil, err + } + material, err := authenticateAKA(ctx, session.provider.aka, session.request.Identity, challenge) + if err != nil { + return nil, err + } + if len(material.auts) == 0 && useSecurity { + if err := session.activateIPSec(ctx, agreement, material.ck, material.ik); err != nil { + clearAKAMaterial(&material) + return nil, err + } + } + credentials, err := newDigestCredentials( + session.identity.private, + material.password, + "sip:"+session.identity.domain, + "REGISTER", + 1, + ) + if err != nil { + clearAKAMaterial(&material) + return nil, err + } + auts := base64.StdEncoding.EncodeToString(material.auts) + session.auth = &authenticationState{ + challenge: challenge, + password: append([]byte(nil), material.password...), + auts: auts, + cnonce: credentials.CNonce, + } + clearAKAMaterial(&material) + } + return nil, errors.New("ims: too many SIP authentication challenges") +} + +func challengeFromResponse(response *sipResponse) (digestChallenge, error) { + header := "WWW-Authenticate" + proxy := false + if response.StatusCode == 407 { + header = "Proxy-Authenticate" + proxy = true + } + var lastError error + for _, value := range response.values(header) { + challenge, err := parseDigestChallenge(value, proxy) + if err == nil { + return challenge, nil + } + lastError = err + } + if lastError == nil { + lastError = errors.New("ims: authentication response omitted a digest challenge") + } + return digestChallenge{}, lastError +} + +func (session *Session) buildRegister( + cseq uint32, + expires int, + authorizationHeader string, + authorization string, +) ([]byte, error) { + branch, err := randomHex(12) + if err != nil { + return nil, err + } + local := session.conn.LocalAddr().String() + contactAddress := session.contactAddress() + transportUpper := strings.ToUpper(session.transport) + requestURI := "sip:" + session.identity.domain + routeURI := "sip:" + session.endpoint.address() + ";transport=" + session.transport + ";lr" + contact := fmt.Sprintf( + ";+sip.instance=\"<%s>\";+g.3gpp.smsip", + session.identity.user, + contactAddress, + session.transport, + session.instanceID, + ) + lines := []string{ + "REGISTER " + requestURI + " SIP/2.0", + fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, local, branch), + "Max-Forwards: 70", + "Route: <" + routeURI + ">", + "From: <" + session.identity.public + ">;tag=" + session.fromTag, + "To: <" + session.identity.public + ">", + "Call-ID: " + session.callID, + fmt.Sprintf("CSeq: %d REGISTER", cseq), + "Contact: " + contact, + fmt.Sprintf("Expires: %d", expires), + "Supported: path, gruu", + "Allow: REGISTER, OPTIONS", + "User-Agent: " + session.provider.config.UserAgent, + } + if session.securityOffered() { + lines = append( + lines, + "Security-Client: "+session.securityProposal.headerValue(), + "Require: sec-agree", + "Proxy-Require: sec-agree", + ) + if session.securityActive { + lines = append(lines, "Security-Verify: "+session.securityAgreement.verifyValue) + } + } + if authorization != "" { + if session.securityOffered() { + integrity := "no" + if session.securityActive { + integrity = "yes" + } + authorization += ", integrity-protected=" + integrity + } + lines = append(lines, authorizationHeader+": "+authorization) + } else if cseq == 1 && session.securityOffered() { + lines = append(lines, "Authorization: "+session.emptyDigestAuthorization()) + } + lines = append(lines, "Content-Length: 0", "", "") + return []byte(strings.Join(lines, "\r\n")), nil +} + +func (session *Session) exchange(ctx context.Context, request []byte, cseq uint32) (*sipResponse, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if session.runtimeStarted { + return session.exchangeRuntime(ctx, request, sipTransactionKey{ + callID: session.callID, + cseq: cseq, + method: "REGISTER", + }) + } + deadline := time.Now().Add(session.provider.config.TransactionTimeout) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(deadline) { + deadline = contextDeadline + } + readUDP := session.protectedUDP + protectedUDP := session.securityActive && session.transport == "udp" && readUDP != nil + if err := session.conn.SetDeadline(deadline); err != nil { + return nil, fmt.Errorf("ims: set SIP transaction deadline: %w", err) + } + if protectedUDP { + if err := readUDP.SetReadDeadline(deadline); err != nil { + return nil, fmt.Errorf("ims: set protected SIP receive deadline: %w", err) + } + } + stopCancellation := context.AfterFunc(ctx, func() { + _ = session.conn.SetDeadline(time.Now()) + if protectedUDP { + _ = readUDP.SetReadDeadline(time.Now()) + } + }) + defer stopCancellation() + if _, err := session.conn.Write(request); err != nil { + return nil, fmt.Errorf("ims: send SIP REGISTER: %w", err) + } + + for { + var response *sipResponse + var err error + if session.transport == "tcp" { + response, err = readSIPResponse(session.reader) + } else if protectedUDP { + packet := make([]byte, 65535) + var count int + var remote *net.UDPAddr + count, remote, err = readUDP.ReadFromUDP(packet) + if err == nil && !session.validProtectedUDPSource(remote) { + continue + } + if err == nil { + response, err = parseSIPResponse(packet[:count]) + } + } else { + packet := make([]byte, 65535) + var count int + count, err = session.conn.Read(packet) + if err == nil { + response, err = parseSIPResponse(packet[:count]) + } + } + if err != nil { + if contextErr := ctx.Err(); contextErr != nil { + return nil, contextErr + } + return nil, fmt.Errorf("ims: receive SIP REGISTER response: %w", err) + } + if !strings.EqualFold(strings.TrimSpace(response.value("Call-ID")), session.callID) { + continue + } + responseCSeq, method, err := cseqNumber(response.value("CSeq")) + if err != nil || responseCSeq != cseq || method != "REGISTER" { + continue + } + if response.StatusCode >= 100 && response.StatusCode < 200 { + continue + } + return response, nil + } +} + +func (session *Session) applyRegistrationEvidence(response *sipResponse) error { + if session.provider.config.SecurityMode == SecurityRequired && !session.securityActive { + session.evidence.Registered = false + session.evidence.RegistrationState = "security_failed" + return ErrIPSecAgreementRequired + } + associated := splitHeaderValues(response.values("P-Associated-URI")) + contacts := splitHeaderValues(response.values("Contact")) + serviceRoutes := splitHeaderValues(response.values("Service-Route")) + registeredContact := "" + smsConfirmed := false + instanceLower := strings.ToLower(session.instanceID) + contactURILower := strings.ToLower(fmt.Sprintf( + "sip:%s@%s;transport=%s", + session.identity.user, + session.contactAddress(), + session.transport, + )) + for _, contact := range contacts { + lower := strings.ToLower(contact) + matchesThisSession := strings.Contains(lower, instanceLower) || + strings.Contains(lower, contactURILower) + if matchesThisSession { + registeredContact = contact + smsConfirmed = strings.Contains(lower, "+g.3gpp.smsip") + if strings.Contains(lower, instanceLower) { + break + } + } + } + expiry := registrationExpiry(response, contacts, session.provider.config.RegistrationExpiry) + if expiry <= 0 { + session.evidence.Registered = false + session.evidence.RegistrationState = "rejected_zero_expiry" + session.evidence.LastSIPCode = response.StatusCode + session.clearAuthentication() + return fmt.Errorf("%w: registrar granted zero expiry", ErrRegistrationRejected) + } + session.expiresAt = time.Now().Add(expiry) + session.smsContactConfirmed = smsConfirmed + session.evidence = vowifi.IMSEvidence{ + Registered: true, + RegistrationState: "registered", + PAssociatedURI: append([]string(nil), associated...), + AssociatedIdentities: append([]string(nil), associated...), + RegisteredContact: registeredContact, + ServiceRoute: append([]string(nil), serviceRoutes...), + Transport: session.transport, + LastSIPCode: response.StatusCode, + SecurityMode: session.effectiveSecurityMode(), + SecurityVerified: session.securityActive, + } + session.clearAuthentication() + return nil +} + +func (session *Session) refreshLoop() { + defer close(session.refreshDone) + for { + session.mu.Lock() + if session.closed || !session.evidence.Registered { + session.mu.Unlock() + return + } + delay := refreshDelay(time.Until(session.expiresAt)) + session.mu.Unlock() + + timer := time.NewTimer(delay) + select { + case <-session.refreshContext.Done(): + if !timer.Stop() { + <-timer.C + } + return + case <-timer.C: + } + if err := session.refreshOnce(session.refreshContext); err != nil { + session.publishFailure(err) + return + } + } +} + +func refreshDelay(remaining time.Duration) time.Duration { + if remaining <= 0 { + return 0 + } + delay := remaining * 4 / 5 + if remaining > time.Minute && delay > remaining-30*time.Second { + delay = remaining - 30*time.Second + } + if delay < 100*time.Millisecond { + delay = 100 * time.Millisecond + } + return delay +} + +func (session *Session) refreshOnce(ctx context.Context) error { + session.mu.Lock() + defer session.mu.Unlock() + switch { + case session.closed: + return ErrSessionClosed + case !session.evidence.Registered: + return vowifi.ErrIMSNotRegistered + } + response, err := session.register(ctx, int(session.provider.config.RegistrationExpiry/time.Second)) + if err != nil { + session.failRefresh() + return fmt.Errorf("ims: refresh registration: %w", err) + } + if response.StatusCode != 200 { + session.failRefresh() + return fmt.Errorf("ims: refresh registration returned SIP %d", response.StatusCode) + } + if err := session.applyRegistrationEvidence(response); err != nil { + session.failRefresh() + return err + } + return nil +} + +func (session *Session) failRefresh() { + session.evidence.Registered = false + session.evidence.RegistrationState = "refresh_failed" + session.smsContactConfirmed = false + session.expiresAt = time.Time{} + session.clearAuthentication() +} + +func (session *Session) publishFailure(err error) { + if err == nil { + return + } + session.failureOnce.Do(func() { + session.failures <- err + }) +} + +func (session *Session) Failures() <-chan error { + return session.failures +} + +func (session *Session) clearAuthentication() { + if session.auth == nil { + return + } + for index := range session.auth.password { + session.auth.password[index] = 0 + } + session.auth = nil +} + +func registrationExpiry(response *sipResponse, contacts []string, fallback time.Duration) time.Duration { + for _, contact := range contacts { + if seconds, ok := parameterSeconds(contact, "expires"); ok { + return boundedExpiry(seconds) + } + } + if seconds, err := strconv.Atoi(strings.TrimSpace(response.value("Expires"))); err == nil { + return boundedExpiry(seconds) + } + return fallback +} + +func parameterSeconds(value string, name string) (int, bool) { + lower := strings.ToLower(value) + needle := strings.ToLower(name) + "=" + index := strings.Index(lower, needle) + if index < 0 { + return 0, false + } + start := index + len(needle) + end := start + for end < len(value) && value[end] >= '0' && value[end] <= '9' { + end++ + } + if end == start { + return 0, false + } + seconds, err := strconv.Atoi(value[start:end]) + return seconds, err == nil +} + +func boundedExpiry(seconds int) time.Duration { + if seconds <= 0 { + return 0 + } + expiry := time.Duration(seconds) * time.Second + if expiry > 24*time.Hour { + return 24 * time.Hour + } + return expiry +} + +func (session *Session) Evidence() vowifi.IMSEvidence { + session.mu.Lock() + defer session.mu.Unlock() + evidence := cloneEvidence(session.evidence) + if session.closed { + evidence.Registered = false + evidence.RegistrationState = "closed" + } else if evidence.Registered && !session.expiresAt.IsZero() && !time.Now().Before(session.expiresAt) { + evidence.Registered = false + evidence.RegistrationState = "expired" + } + return evidence +} + +func (session *Session) EnableSMS(ctx context.Context) (vowifi.SMSEvidence, error) { + if ctx == nil { + ctx = context.Background() + } + if err := ctx.Err(); err != nil { + return vowifi.SMSEvidence{}, err + } + session.mu.Lock() + defer session.mu.Unlock() + // This method is deliberately evidence-only. It never sends SIP MESSAGE, + // modem CMGS commands, or any dial request. A registrar-confirmed feature + // tag on this session's own Contact is the minimum readiness proof. + switch { + case session.closed: + return vowifi.SMSEvidence{}, ErrSessionClosed + case !session.evidence.Registered: + return vowifi.SMSEvidence{}, vowifi.ErrIMSNotRegistered + case !session.expiresAt.IsZero() && !time.Now().Before(session.expiresAt): + return vowifi.SMSEvidence{}, ErrRegistrationExpired + case !session.smsContactConfirmed: + return vowifi.SMSEvidence{Ready: false}, ErrSMSCapabilityNotConfirmed + default: + return vowifi.SMSEvidence{Ready: true}, nil + } +} + +func (session *Session) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + session.refreshCancel() + select { + case <-session.refreshDone: + case <-ctx.Done(): + _ = session.conn.Close() + <-session.refreshDone + } + + session.mu.Lock() + if session.closed { + session.mu.Unlock() + return nil + } + var unregisterErr error + if session.evidence.Registered && ctx.Err() == nil { + response, err := session.register(ctx, 0) + if err != nil { + unregisterErr = err + } else if response.StatusCode != 200 { + unregisterErr = fmt.Errorf("ims: SIP deregistration returned %d", response.StatusCode) + } + } + session.closed = true + session.evidence.Registered = false + session.evidence.RegistrationState = "closed" + session.smsContactConfirmed = false + session.clearAuthentication() + session.mu.Unlock() + var cleanupErrors []error + if unregisterErr != nil { + cleanupErrors = append(cleanupErrors, unregisterErr) + } + if err := session.conn.Close(); err != nil { + cleanupErrors = append(cleanupErrors, err) + } + if session.protectedTCP != nil { + if err := session.protectedTCP.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + cleanupErrors = append(cleanupErrors, err) + } + } + if session.protectedUDP != nil { + if err := session.protectedUDP.Close(); err != nil && !errors.Is(err, net.ErrClosed) { + cleanupErrors = append(cleanupErrors, err) + } + } + session.closeInboundConnections() + session.receiveDone.Wait() + if session.ipsecHandle != nil { + if err := session.ipsecHandle.Close(ctx); err != nil { + cleanupErrors = append(cleanupErrors, err) + } + } + return errors.Join(cleanupErrors...) +} + +func cloneEvidence(evidence vowifi.IMSEvidence) vowifi.IMSEvidence { + evidence.PAssociatedURI = append([]string(nil), evidence.PAssociatedURI...) + evidence.AssociatedIdentities = append([]string(nil), evidence.AssociatedIdentities...) + evidence.ServiceRoute = append([]string(nil), evidence.ServiceRoute...) + return evidence +} + +func randomHex(size int) (string, error) { + value := make([]byte, size) + if _, err := rand.Read(value); err != nil { + return "", fmt.Errorf("ims: create random SIP identifier: %w", err) + } + return hex.EncodeToString(value), nil +} + +func randomUUID() (string, error) { + value := make([]byte, 16) + if _, err := rand.Read(value); err != nil { + return "", fmt.Errorf("ims: create SIP instance identifier: %w", err) + } + value[6] = (value[6] & 0x0f) | 0x40 + value[8] = (value[8] & 0x3f) | 0x80 + return fmt.Sprintf( + "%x-%x-%x-%x-%x", + value[0:4], value[4:6], value[6:8], value[8:10], value[10:16], + ), nil +} + +func addressHost(address net.Addr) string { + if address == nil { + return "localhost" + } + host, _, err := net.SplitHostPort(address.String()) + if err == nil { + return strings.Trim(host, "[]") + } + return strings.Trim(address.String(), "[]") +} + +func addressIP(address net.Addr) net.IP { + host := addressHost(address) + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return nil + } + return append(net.IP(nil), ip...) +} + +func digitsBetween(value string, minimum int, maximum int) bool { + if len(value) < minimum || len(value) > maximum { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} + +var _ vowifi.IMSProvider = (*Provider)(nil) +var _ vowifi.IMSSession = (*Session)(nil) +var _ vowifi.RuntimeFailureNotifier = (*Session)(nil) diff --git a/internal/vowifi/ims/provider_test.go b/internal/vowifi/ims/provider_test.go new file mode 100644 index 0000000..1f71a48 --- /dev/null +++ b/internal/vowifi/ims/provider_test.go @@ -0,0 +1,493 @@ +package ims + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net" + "strconv" + "strings" + "testing" + "time" + + "vocat/internal/vowifi" +) + +type evidenceTunnel struct { + evidence vowifi.TunnelEvidence +} + +func (tunnel evidenceTunnel) Evidence() vowifi.TunnelEvidence { + return tunnel.evidence +} + +func (evidenceTunnel) Close(context.Context) error { + return nil +} + +func TestProviderRegisterAKAParseEvidenceAndClose(t *testing.T) { + for _, test := range []struct { + name string + confirmSMS bool + wantSMSReady bool + }{ + {name: "registrar confirms SMS feature tag", confirmSMS: true, wantSMSReady: true}, + {name: "registrar omits SMS feature tag", confirmSMS: false, wantSMSReady: false}, + } { + t.Run(test.name, func(t *testing.T) { + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("ListenUDP() error = %v", err) + } + defer listener.Close() + if err := listener.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatalf("SetDeadline() error = %v", err) + } + + nonceBytes := make([]byte, 32) + for index := range nonceBytes { + nonceBytes[index] = byte(index + 1) + } + nonce := base64.StdEncoding.EncodeToString(nonceBytes) + serverDone := make(chan error, 1) + go func() { + serverDone <- serveRegistration(listener, nonce, test.confirmSMS) + }() + + aka := &recordingAKA{ + result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4, 5, 6, 7, 8}}, + } + provider, err := NewProvider(aka, Config{ + PCSCF: listener.LocalAddr().String(), + LocalAddress: "127.0.0.1", + Transport: "udp", + TransactionTimeout: 3 * time.Second, + SecurityMode: SecurityDisabled, + }) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + session, err := provider.Start(context.Background(), vowifi.IMSRequest{ + DeviceID: "ec20", + Identity: vowifi.SIMIdentity{ + ICCID: "8901000000000000000", + IMSI: "001010123456789", + HomeMCC: "001", + HomeMNC: "01", + }, + Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{ + Established: true, + LocalIPv4: "127.0.0.1", + PCSCF: []string{listener.LocalAddr().String()}, + }}, + }) + if err != nil { + t.Fatalf("Provider.Start() error = %v", err) + } + + evidence := session.Evidence() + if !evidence.Registered || evidence.LastSIPCode != 200 || + evidence.RegistrationState != "registered" { + t.Fatalf("evidence = %#v", evidence) + } + if len(evidence.AssociatedIdentities) != 2 || + len(evidence.PAssociatedURI) != 2 || + len(evidence.ServiceRoute) != 1 { + t.Fatalf("parsed evidence = %#v", evidence) + } + if evidence.RegisteredContact == "" { + t.Fatalf("registered contact was not correlated: %#v", evidence) + } + concrete, ok := session.(*Session) + if !ok { + t.Fatalf("session type = %T", session) + } + if err := concrete.refreshOnce(context.Background()); err != nil { + t.Fatalf("refreshOnce() error = %v", err) + } + evidence = session.Evidence() + if !evidence.Registered || evidence.RegistrationState != "registered" { + t.Fatalf("evidence after refresh = %#v", evidence) + } + number, source, ok := vowifi.ExtractAssociatedMSISDN(evidence) + if !ok || number != "+8613800138000" || source != vowifi.PhoneSourcePAssociatedURI { + t.Fatalf("ExtractAssociatedMSISDN() = (%q, %q, %t)", number, source, ok) + } + sms, smsErr := session.EnableSMS(context.Background()) + if test.wantSMSReady { + if smsErr != nil || !sms.Ready { + t.Fatalf("EnableSMS() = (%#v, %v), want ready", sms, smsErr) + } + } else { + if !errors.Is(smsErr, ErrSMSCapabilityNotConfirmed) || sms.Ready { + t.Fatalf("EnableSMS() = (%#v, %v), want strict not-ready", sms, smsErr) + } + } + if err := session.Close(context.Background()); err != nil { + t.Fatalf("Close() error = %v", err) + } + if session.Evidence().Registered { + t.Fatal("Evidence().Registered = true after Close") + } + if err := <-serverDone; err != nil { + t.Fatalf("registrar error = %v", err) + } + if len(aka.challenges) != 1 { + t.Fatalf("AKA challenge count = %d, want 1", len(aka.challenges)) + } + }) + } +} + +func TestRefreshFailureRevokesRegistrationEvidence(t *testing.T) { + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("ListenUDP() error = %v", err) + } + defer listener.Close() + if err := listener.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatalf("SetDeadline() error = %v", err) + } + nonce := base64.StdEncoding.EncodeToString(make([]byte, 32)) + serverDone := make(chan error, 1) + go func() { + serverDone <- serveRefreshFailure(listener, nonce) + }() + + provider, err := NewProvider( + &recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}, + Config{ + PCSCF: listener.LocalAddr().String(), + LocalAddress: "127.0.0.1", + Transport: "udp", + TransactionTimeout: 3 * time.Second, + SecurityMode: SecurityDisabled, + }, + ) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + session, err := provider.Start(context.Background(), vowifi.IMSRequest{ + Identity: vowifi.SIMIdentity{ + IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01", + }, + Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{ + Established: true, + LocalIPv4: "127.0.0.1", + PCSCF: []string{listener.LocalAddr().String()}, + }}, + }) + if err != nil { + t.Fatalf("Provider.Start() error = %v", err) + } + concrete := session.(*Session) + if err := concrete.refreshOnce(context.Background()); err == nil { + t.Fatal("refreshOnce() error = nil, want SIP rejection") + } + evidence := session.Evidence() + if evidence.Registered || evidence.RegistrationState != "refresh_failed" { + t.Fatalf("evidence after failed refresh = %#v", evidence) + } + if sms, err := session.EnableSMS(context.Background()); sms.Ready || !errors.Is(err, vowifi.ErrIMSNotRegistered) { + t.Fatalf("EnableSMS() = (%#v, %v), want IMS not registered", sms, err) + } + if err := session.Close(context.Background()); err != nil { + t.Fatalf("Close() error = %v", err) + } + if err := <-serverDone; err != nil { + t.Fatalf("registrar error = %v", err) + } +} + +func serveRegistration(listener *net.UDPConn, nonce string, confirmSMS bool) error { + var callID string + for step := 0; step < 4; step++ { + packet := make([]byte, 65535) + count, remote, err := listener.ReadFromUDP(packet) + if err != nil { + return err + } + startLine, headers, err := parseTestRequest(packet[:count]) + if err != nil { + return err + } + if !strings.HasPrefix(startLine, "REGISTER sip:ims.mnc001.mcc001.3gppnetwork.org SIP/2.0") { + return fmt.Errorf("unexpected start line %q", startLine) + } + for _, forbidden := range []string{ + "p-access-network-info", + "p-visited-network-id", + "p-preferred-identity", + } { + if headers[forbidden] != "" { + return fmt.Errorf( + "REGISTER unexpectedly included %s: %q", + forbidden, + headers[forbidden], + ) + } + } + if step == 0 { + if headers["authorization"] != "" { + return errors.New("initial REGISTER unexpectedly authenticated") + } + callID = headers["call-id"] + response := testResponse( + 401, + "Unauthorized", + callID, + headers["cseq"], + []string{ + `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, + }, + ) + if _, err := listener.WriteToUDP(response, remote); err != nil { + return err + } + continue + } + if headers["call-id"] != callID { + return errors.New("Call-ID changed within registration") + } + if step == 1 { + if headers["authorization"] == "" { + return errors.New("authenticated REGISTER omitted Authorization") + } + if err := verifyTestAuthorization(headers["authorization"], nonce); err != nil { + return err + } + contact := headers["contact"] + extraContacts := []string(nil) + if !confirmSMS { + contact = strings.Replace(contact, ";+g.3gpp.smsip", "", 1) + extraContacts = append( + extraContacts, + "Contact: ;+g.3gpp.smsip;expires=600", + ) + } + responseHeaders := []string{ + "P-Associated-URI: , ", + "Contact: " + contact + ";expires=600", + "Service-Route: ", + } + responseHeaders = append(responseHeaders, extraContacts...) + response := testResponse( + 200, + "OK", + callID, + headers["cseq"], + responseHeaders, + ) + if _, err := listener.WriteToUDP(response, remote); err != nil { + return err + } + continue + } + if step == 2 { + if headers["expires"] == "0" { + return errors.New("refresh REGISTER used zero expiry") + } + if headers["authorization"] != "" { + return errors.New("refresh reused the one-time AKAv1 RES") + } + contact := headers["contact"] + extraContacts := []string(nil) + if !confirmSMS { + contact = strings.Replace(contact, ";+g.3gpp.smsip", "", 1) + extraContacts = append( + extraContacts, + "Contact: ;+g.3gpp.smsip;expires=600", + ) + } + responseHeaders := []string{ + "P-Associated-URI: , ", + "Contact: " + contact + ";expires=600", + "Service-Route: ", + } + responseHeaders = append(responseHeaders, extraContacts...) + response := testResponse( + 200, + "OK", + callID, + headers["cseq"], + responseHeaders, + ) + if _, err := listener.WriteToUDP(response, remote); err != nil { + return err + } + continue + } + if headers["expires"] != "0" { + return fmt.Errorf("deregister Expires = %q, want 0", headers["expires"]) + } + if _, err := listener.WriteToUDP( + testResponse(200, "OK", callID, headers["cseq"], nil), + remote, + ); err != nil { + return err + } + } + return nil +} + +func serveRefreshFailure(listener *net.UDPConn, nonce string) error { + var callID string + for step := 0; step < 3; step++ { + packet := make([]byte, 65535) + count, remote, err := listener.ReadFromUDP(packet) + if err != nil { + return err + } + _, headers, err := parseTestRequest(packet[:count]) + if err != nil { + return err + } + if step == 0 { + callID = headers["call-id"] + if _, err := listener.WriteToUDP( + testResponse( + 401, + "Unauthorized", + callID, + headers["cseq"], + []string{ + `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, + }, + ), + remote, + ); err != nil { + return err + } + continue + } + if step == 1 { + if headers["authorization"] == "" { + return errors.New("authenticated REGISTER omitted Authorization") + } + if _, err := listener.WriteToUDP( + testResponse( + 200, + "OK", + callID, + headers["cseq"], + []string{ + "P-Associated-URI: ", + "Contact: " + headers["contact"] + ";expires=600", + }, + ), + remote, + ); err != nil { + return err + } + continue + } + if headers["authorization"] != "" { + return errors.New("refresh reused the one-time AKAv1 RES") + } + if _, err := listener.WriteToUDP( + testResponse(503, "Service Unavailable", callID, headers["cseq"], nil), + remote, + ); err != nil { + return err + } + } + return nil +} + +func parseTestRequest(packet []byte) (string, map[string]string, error) { + text := strings.ReplaceAll(string(packet), "\r\n", "\n") + lines := strings.Split(text, "\n") + if len(lines) < 2 { + return "", nil, errors.New("short SIP request") + } + headers := make(map[string]string) + for _, line := range lines[1:] { + if line == "" { + break + } + name, value, found := strings.Cut(line, ":") + if !found { + return "", nil, fmt.Errorf("malformed request header %q", line) + } + headers[strings.ToLower(strings.TrimSpace(name))] = strings.TrimSpace(value) + } + return lines[0], headers, nil +} + +func verifyTestAuthorization(value string, nonce string) error { + scheme, parameters, found := strings.Cut(value, " ") + if !found || scheme != "Digest" { + return errors.New("invalid Authorization scheme") + } + directives, err := parseAuthDirectives(parameters) + if err != nil { + return err + } + expected := digestResponse( + "001010123456789@ims.mnc001.mcc001.3gppnetwork.org", + "ims.mnc001.mcc001.3gppnetwork.org", + []byte{1, 2, 3, 4, 5, 6, 7, 8}, + "REGISTER", + "sip:ims.mnc001.mcc001.3gppnetwork.org", + nonce, + directives["nc"], + directives["cnonce"], + directives["qop"], + ) + if directives["response"] != expected { + return fmt.Errorf("digest response = %q, want %q", directives["response"], expected) + } + if directives["algorithm"] != "AKAv1-MD5" || directives["qop"] != "auth" { + return fmt.Errorf("digest directives = %#v", directives) + } + return nil +} + +func TestRegistrationRejectionErrorIncludesSafeDiagnostics(t *testing.T) { + err := registrationRejectionError(&sipResponse{ + StatusCode: 403, + Reason: "Forbidden\r\nignored", + Headers: map[string][]string{ + "reason": {`SIP;cause=403;text="not provisioned"`}, + "warning": {`399 pcscf "subscriber barred"`}, + "www-authenticate": {`Digest nonce="must-not-leak"`}, + }, + }, "authenticated") + if !errors.Is(err, ErrRegistrationRejected) { + t.Fatalf("error does not wrap ErrRegistrationRejected: %v", err) + } + message := err.Error() + for _, expected := range []string{ + "authenticated REGISTER was rejected", + "SIP 403 Forbidden ignored", + `Reason: SIP;cause=403;text="not provisioned"`, + `Warning: 399 pcscf "subscriber barred"`, + } { + if !strings.Contains(message, expected) { + t.Fatalf("error %q does not contain %q", message, expected) + } + } + if strings.Contains(message, "must-not-leak") { + t.Fatalf("error leaked an authentication header: %q", message) + } +} + +func testResponse( + status int, + reason string, + callID string, + cseq string, + extraHeaders []string, +) []byte { + lines := []string{ + "SIP/2.0 " + strconv.Itoa(status) + " " + reason, + "Call-ID: " + callID, + "CSeq: " + cseq, + } + lines = append(lines, extraHeaders...) + lines = append(lines, "Content-Length: 0", "", "") + return []byte(strings.Join(lines, "\r\n")) +} diff --git a/internal/vowifi/ims/security.go b/internal/vowifi/ims/security.go new file mode 100644 index 0000000..9a8caaa --- /dev/null +++ b/internal/vowifi/ims/security.go @@ -0,0 +1,747 @@ +package ims + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "net" + "sort" + "strconv" + "strings" +) + +type SecurityMode string + +const ( + // SecurityRequired is the production default. A 401 response without a + // supported ipsec-3gpp Security-Server offer fails closed. + SecurityRequired SecurityMode = "required" + // SecurityOptional advertises ipsec-3gpp but permits a carrier that + // explicitly omits Security-Server to continue on the tunnel in plain SIP. + SecurityOptional SecurityMode = "optional" + // SecurityDisabled is intended for controlled interoperability testing. + SecurityDisabled SecurityMode = "disabled" +) + +var ( + ErrIPSecAgreementRequired = errors.New("ims: a supported ipsec-3gpp security agreement is required") + ErrIPSecInstall = errors.New("ims: install ipsec-3gpp security associations") +) + +// IPSecSAConfig is the complete, evidence-derived 3GPP transport-mode SA set. +// The two UE SPIs identify inbound SAs; the two P-CSCF SPIs identify outbound +// SAs. EncryptionKey and IntegrityKey must be discarded after Install returns. +type IPSecSAConfig struct { + LocalIP net.IP + RemoteIP net.IP + + UEClientSPI uint32 + UEServerSPI uint32 + PCSCFClientSPI uint32 + PCSCFServerSPI uint32 + + UEClientPort int + UEServerPort int + PCSCFClientPort int + PCSCFServerPort int + + EncryptionKey []byte + IntegrityKey []byte +} + +type IPSecSAHandle interface { + Close(context.Context) error +} + +type IPSecSAInstaller interface { + Install(context.Context, IPSecSAConfig) (IPSecSAHandle, error) +} + +type securityProposal struct { + spiClient uint32 + spiServer uint32 + portClient int + portServer int +} + +func newSecurityProposal(localIP net.IP, configuredClientPort int, configuredServerPort int) (securityProposal, error) { + spiClient, err := randomSPI(0) + if err != nil { + return securityProposal{}, err + } + spiServer, err := randomSPI(spiClient) + if err != nil { + return securityProposal{}, err + } + portClient := configuredClientPort + if portClient == 0 { + portClient, err = availableProtectedPort(localIP, 0) + if err != nil { + return securityProposal{}, err + } + } + portServer := configuredServerPort + if portServer == 0 { + portServer, err = availableProtectedPort(localIP, portClient) + if err != nil { + return securityProposal{}, err + } + } + if !validProtectedPort(portClient) || !validProtectedPort(portServer) || portClient == portServer { + return securityProposal{}, errors.New("ims: protected UE ports must be distinct non-standard SIP ports") + } + return securityProposal{ + spiClient: spiClient, + spiServer: spiServer, + portClient: portClient, + portServer: portServer, + }, nil +} + +func (proposal securityProposal) headerValue() string { + return fmt.Sprintf( + "ipsec-3gpp;q=1.000;alg=hmac-sha-1-96;prot=esp;mod=trans;ealg=aes-cbc;spi-c=%010d;spi-s=%010d;port-c=%d;port-s=%d", + proposal.spiClient, + proposal.spiServer, + proposal.portClient, + proposal.portServer, + ) +} + +func randomSPI(exclude uint32) (uint32, error) { + for attempts := 0; attempts < 16; attempts++ { + var value [4]byte + if _, err := rand.Read(value[:]); err != nil { + return 0, fmt.Errorf("ims: create protected SPI: %w", err) + } + spi := binary.BigEndian.Uint32(value[:]) + if spi >= 256 && spi != exclude { + return spi, nil + } + } + return 0, errors.New("ims: could not allocate a protected SPI") +} + +func availableProtectedPort(localIP net.IP, exclude int) (int, error) { + for attempts := 0; attempts < 32; attempts++ { + var value [2]byte + if _, err := rand.Read(value[:]); err != nil { + return 0, fmt.Errorf("ims: create protected port: %w", err) + } + port := 20000 + int(binary.BigEndian.Uint16(value[:]))%44000 + if port == exclude || !validProtectedPort(port) { + continue + } + address := &net.TCPAddr{IP: append(net.IP(nil), localIP...), Port: port} + listener, err := net.ListenTCP("tcp", address) + if err != nil { + continue + } + _ = listener.Close() + packet, err := net.ListenUDP("udp", &net.UDPAddr{IP: append(net.IP(nil), localIP...), Port: port}) + if err != nil { + continue + } + _ = packet.Close() + return port, nil + } + return 0, errors.New("ims: no protected local port is available") +} + +func validProtectedPort(port int) bool { + return port > 1024 && port <= 65535 && port != 5060 && port != 5061 +} + +type securityMechanism struct { + raw string + name string + algorithm string + protocol string + mode string + encryption string + spiClient uint32 + spiServer uint32 + portClient int + portServer int + preference int +} + +type securityAgreement struct { + selected securityMechanism + verifyValue string +} + +func parseSecurityAgreement(values []string, proposal securityProposal) (securityAgreement, error) { + items := splitHeaderValues(values) + if len(items) == 0 { + return securityAgreement{}, ErrIPSecAgreementRequired + } + candidates := make([]securityMechanism, 0, len(items)) + for _, item := range items { + mechanism, err := parseSecurityMechanism(item) + if err != nil { + name := strings.ToLower(strings.TrimSpace(strings.SplitN(item, ";", 2)[0])) + if name == "ipsec-3gpp" { + return securityAgreement{}, fmt.Errorf( + "ims: malformed ipsec-3gpp Security-Server: %w", + err, + ) + } + continue + } + if !strings.EqualFold(mechanism.name, "ipsec-3gpp") || + !strings.EqualFold(mechanism.algorithm, "hmac-sha-1-96") || + !strings.EqualFold(mechanism.protocol, "esp") || + !strings.EqualFold(mechanism.mode, "trans") || + !strings.EqualFold(mechanism.encryption, "aes-cbc") { + continue + } + if mechanism.spiClient == 0 || mechanism.spiServer == 0 || + mechanism.spiClient == mechanism.spiServer || + mechanism.spiClient == proposal.spiClient || + mechanism.spiClient == proposal.spiServer || + mechanism.spiServer == proposal.spiClient || + mechanism.spiServer == proposal.spiServer || + !validProtectedPort(mechanism.portClient) || + !validProtectedPort(mechanism.portServer) || + mechanism.portClient == mechanism.portServer { + continue + } + candidates = append(candidates, mechanism) + } + if len(candidates) == 0 { + return securityAgreement{}, ErrIPSecAgreementRequired + } + sort.SliceStable(candidates, func(left int, right int) bool { + return candidates[left].preference > candidates[right].preference + }) + return securityAgreement{ + selected: candidates[0], + verifyValue: strings.Join(items, ", "), + }, nil +} + +func parseSecurityMechanism(value string) (securityMechanism, error) { + parts := strings.Split(value, ";") + if len(parts) == 0 { + return securityMechanism{}, errors.New("ims: empty Security-Server mechanism") + } + mechanism := securityMechanism{ + raw: strings.TrimSpace(value), + name: strings.ToLower(strings.TrimSpace(parts[0])), + protocol: "esp", + mode: "trans", + encryption: "null", + } + parameters := make(map[string]string) + for _, raw := range parts[1:] { + key, parameterValue, found := strings.Cut(strings.TrimSpace(raw), "=") + if !found { + return securityMechanism{}, errors.New("ims: malformed Security-Server parameter") + } + key = strings.ToLower(strings.TrimSpace(key)) + parameterValue = strings.Trim(strings.TrimSpace(parameterValue), `"`) + if key == "" || parameterValue == "" { + return securityMechanism{}, errors.New("ims: empty Security-Server parameter") + } + if _, duplicate := parameters[key]; duplicate { + return securityMechanism{}, errors.New("ims: duplicate Security-Server parameter") + } + parameters[key] = parameterValue + } + mechanism.algorithm = strings.ToLower(parameters["alg"]) + if value := parameters["prot"]; value != "" { + mechanism.protocol = strings.ToLower(value) + } + if value := parameters["mod"]; value != "" { + mechanism.mode = strings.ToLower(value) + } + if value := parameters["ealg"]; value != "" { + mechanism.encryption = strings.ToLower(value) + } + var err error + if mechanism.spiClient, err = decimalUint32(parameters["spi-c"]); err != nil { + return securityMechanism{}, err + } + if mechanism.spiServer, err = decimalUint32(parameters["spi-s"]); err != nil { + return securityMechanism{}, err + } + if mechanism.portClient, err = decimalPort(parameters["port-c"]); err != nil { + return securityMechanism{}, err + } + if mechanism.portServer, err = decimalPort(parameters["port-s"]); err != nil { + return securityMechanism{}, err + } + mechanism.preference, err = preferenceValue(parameters["q"]) + if err != nil { + return securityMechanism{}, err + } + return mechanism, nil +} + +func decimalUint32(value string) (uint32, error) { + if value == "" || len(value) > 10 { + return 0, errors.New("ims: invalid Security-Server SPI") + } + parsed, err := strconv.ParseUint(value, 10, 32) + if err != nil { + return 0, errors.New("ims: invalid Security-Server SPI") + } + return uint32(parsed), nil +} + +func decimalPort(value string) (int, error) { + parsed, err := strconv.Atoi(value) + if err != nil || parsed < 1 || parsed > 65535 { + return 0, errors.New("ims: invalid Security-Server port") + } + return parsed, nil +} + +func preferenceValue(value string) (int, error) { + if value == "" { + return 0, nil + } + whole, fraction, found := strings.Cut(value, ".") + if whole != "0" && whole != "1" { + return 0, errors.New("ims: invalid Security-Server preference") + } + if !found { + if whole == "1" { + return 1000, nil + } + return 0, nil + } + if len(fraction) > 3 { + return 0, errors.New("ims: invalid Security-Server preference") + } + for len(fraction) < 3 { + fraction += "0" + } + numeric, err := strconv.Atoi(fraction) + if err != nil || (whole == "1" && numeric != 0) { + return 0, errors.New("ims: invalid Security-Server preference") + } + if whole == "1" { + return 1000, nil + } + return numeric, nil +} + +func expandIPSecKeys(ck []byte, ik []byte) (encryption []byte, integrity []byte, err error) { + if len(ck) != 16 || len(ik) != 16 { + return nil, nil, errors.New("ims: AKA did not return 16-byte CK and IK") + } + encryption = append([]byte(nil), ck...) + integrity = make([]byte, 20) + copy(integrity, ik) + return encryption, integrity, nil +} + +type xfrmOperation struct { + description string + arguments []string +} + +func buildXFRMInstallPlan(config IPSecSAConfig) ([]xfrmOperation, error) { + if err := validateIPSecSAConfig(config); err != nil { + return nil, err + } + var operations []xfrmOperation + states := []struct { + description string + source net.IP + destination net.IP + spi uint32 + reqid uint32 + }{ + {"outbound UE-client to P-CSCF-server state", config.LocalIP, config.RemoteIP, config.PCSCFServerSPI, clientPairReqID(config)}, + {"inbound P-CSCF-server to UE-client state", config.RemoteIP, config.LocalIP, config.UEClientSPI, clientPairReqID(config)}, + {"inbound P-CSCF-client to UE-server state", config.RemoteIP, config.LocalIP, config.UEServerSPI, serverPairReqID(config)}, + {"outbound UE-server to P-CSCF-client state", config.LocalIP, config.RemoteIP, config.PCSCFClientSPI, serverPairReqID(config)}, + } + for _, state := range states { + operations = append(operations, xfrmOperation{ + description: state.description, + arguments: []string{ + "xfrm", "state", "add", + "src", state.source.String(), + "dst", state.destination.String(), + "proto", "esp", + "spi", fmt.Sprintf("0x%08x", state.spi), + "reqid", strconv.FormatUint(uint64(state.reqid), 10), + "mode", "transport", + "replay-window", "32", + "auth-trunc", "hmac(sha1)", "0x" + hex.EncodeToString(config.IntegrityKey), "96", + "enc", "cbc(aes)", "0x" + hex.EncodeToString(config.EncryptionKey), + }, + }) + } + for _, flow := range xfrmFlows(config) { + for _, protocol := range flow.protocols { + operations = append(operations, xfrmOperation{ + description: flow.description + " " + protocol + " policy", + arguments: []string{ + flow.family, + "xfrm", "policy", "add", + "src", flow.sourcePrefix, + "dst", flow.destinationPrefix, + "proto", protocol, + "sport", strconv.Itoa(flow.sourcePort), + "dport", strconv.Itoa(flow.destinationPort), + "dir", flow.direction, + "priority", "100", + "tmpl", + "src", flow.templateSource.String(), + "dst", flow.templateDestination.String(), + "proto", "esp", + "spi", fmt.Sprintf("0x%08x", flow.spi), + "reqid", strconv.FormatUint(uint64(flow.reqid), 10), + "mode", "transport", + "level", "required", + }, + }) + } + } + return operations, nil +} + +func buildXFRMCleanupPlan(config IPSecSAConfig) []xfrmOperation { + var operations []xfrmOperation + flows := xfrmFlows(config) + for flowIndex := len(flows) - 1; flowIndex >= 0; flowIndex-- { + flow := flows[flowIndex] + for protocolIndex := len(flow.protocols) - 1; protocolIndex >= 0; protocolIndex-- { + protocol := flow.protocols[protocolIndex] + operations = append(operations, xfrmOperation{ + description: "delete " + flow.description + " " + protocol + " policy", + arguments: []string{ + flow.family, + "xfrm", "policy", "delete", + "src", flow.sourcePrefix, + "dst", flow.destinationPrefix, + "proto", protocol, + "sport", strconv.Itoa(flow.sourcePort), + "dport", strconv.Itoa(flow.destinationPort), + "dir", flow.direction, + }, + }) + } + } + states := []struct { + source net.IP + destination net.IP + spi uint32 + }{ + {config.LocalIP, config.RemoteIP, config.PCSCFClientSPI}, + {config.RemoteIP, config.LocalIP, config.UEServerSPI}, + {config.RemoteIP, config.LocalIP, config.UEClientSPI}, + {config.LocalIP, config.RemoteIP, config.PCSCFServerSPI}, + } + for _, state := range states { + operations = append(operations, xfrmOperation{ + description: "delete ipsec-3gpp state", + arguments: []string{ + "xfrm", "state", "delete", + "src", state.source.String(), + "dst", state.destination.String(), + "proto", "esp", + "spi", fmt.Sprintf("0x%08x", state.spi), + }, + }) + } + return operations +} + +type xfrmFlow struct { + description string + family string + sourcePrefix string + destinationPrefix string + sourcePort int + destinationPort int + direction string + templateSource net.IP + templateDestination net.IP + spi uint32 + reqid uint32 + protocols []string +} + +func xfrmFlows(config IPSecSAConfig) []xfrmFlow { + family := "-4" + prefix := "/32" + if config.LocalIP.To4() == nil { + family = "-6" + prefix = "/128" + } + localPrefix := config.LocalIP.String() + prefix + remotePrefix := config.RemoteIP.String() + prefix + return []xfrmFlow{ + { + description: "UE-client to P-CSCF-server", family: family, + sourcePrefix: localPrefix, destinationPrefix: remotePrefix, + sourcePort: config.UEClientPort, destinationPort: config.PCSCFServerPort, + direction: "out", templateSource: config.LocalIP, templateDestination: config.RemoteIP, + spi: config.PCSCFServerSPI, reqid: clientPairReqID(config), + protocols: []string{"tcp", "udp"}, + }, + { + description: "P-CSCF-server to UE-client", family: family, + sourcePrefix: remotePrefix, destinationPrefix: localPrefix, + sourcePort: config.PCSCFServerPort, destinationPort: config.UEClientPort, + direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP, + spi: config.UEClientSPI, reqid: clientPairReqID(config), + protocols: []string{"tcp"}, + }, + { + description: "P-CSCF-client to UE-server", family: family, + sourcePrefix: remotePrefix, destinationPrefix: localPrefix, + sourcePort: config.PCSCFClientPort, destinationPort: config.UEServerPort, + direction: "in", templateSource: config.RemoteIP, templateDestination: config.LocalIP, + spi: config.UEServerSPI, reqid: serverPairReqID(config), + protocols: []string{"tcp", "udp"}, + }, + { + description: "UE-server to P-CSCF-client", family: family, + sourcePrefix: localPrefix, destinationPrefix: remotePrefix, + sourcePort: config.UEServerPort, destinationPort: config.PCSCFClientPort, + direction: "out", templateSource: config.LocalIP, templateDestination: config.RemoteIP, + spi: config.PCSCFClientSPI, reqid: serverPairReqID(config), + protocols: []string{"tcp"}, + }, + } +} + +func clientPairReqID(config IPSecSAConfig) uint32 { + reqid := (config.UEClientSPI ^ config.PCSCFServerSPI) & 0x7fffffff + if reqid == 0 { + return 1 + } + return reqid +} + +func serverPairReqID(config IPSecSAConfig) uint32 { + reqid := (config.UEServerSPI ^ config.PCSCFClientSPI) & 0x7fffffff + if reqid == 0 { + reqid = 2 + } + if reqid == clientPairReqID(config) { + reqid ^= 0x40000000 + if reqid == 0 { + reqid = 2 + } + } + return reqid +} + +func validateIPSecSAConfig(config IPSecSAConfig) error { + local := config.LocalIP + remote := config.RemoteIP + if local == nil || remote == nil || local.IsUnspecified() || remote.IsUnspecified() || + (local.To4() == nil) != (remote.To4() == nil) { + return errors.New("ims: ipsec-3gpp endpoints are invalid or use different IP families") + } + spis := []uint32{ + config.UEClientSPI, config.UEServerSPI, config.PCSCFClientSPI, config.PCSCFServerSPI, + } + seen := make(map[uint32]struct{}, len(spis)) + for _, spi := range spis { + if spi == 0 { + return errors.New("ims: ipsec-3gpp SPI is zero") + } + if _, duplicate := seen[spi]; duplicate { + return errors.New("ims: ipsec-3gpp SPIs must be unique") + } + seen[spi] = struct{}{} + } + ports := []int{ + config.UEClientPort, config.UEServerPort, config.PCSCFClientPort, config.PCSCFServerPort, + } + for _, port := range ports { + if !validProtectedPort(port) { + return errors.New("ims: ipsec-3gpp protected port is invalid") + } + } + if config.UEClientPort == config.UEServerPort || + config.PCSCFClientPort == config.PCSCFServerPort { + return errors.New("ims: client and server protected ports must differ") + } + if len(config.EncryptionKey) != 16 || len(config.IntegrityKey) != 20 { + return errors.New("ims: ipsec-3gpp key length is invalid") + } + return nil +} + +func cloneIPSecSAConfig(config IPSecSAConfig) IPSecSAConfig { + config.LocalIP = append(net.IP(nil), config.LocalIP...) + config.RemoteIP = append(net.IP(nil), config.RemoteIP...) + config.EncryptionKey = append([]byte(nil), config.EncryptionKey...) + config.IntegrityKey = append([]byte(nil), config.IntegrityKey...) + return config +} + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} + +func (session *Session) securityOffered() bool { + return session.provider.config.SecurityMode != SecurityDisabled && !session.securityDeclined +} + +func (session *Session) securityFromResponse(response *sipResponse) (securityAgreement, bool, error) { + if !session.securityOffered() { + return securityAgreement{}, false, nil + } + values := response.values("Security-Server") + if len(splitHeaderValues(values)) == 0 { + if session.provider.config.SecurityMode == SecurityRequired { + return securityAgreement{}, false, ErrIPSecAgreementRequired + } + session.declineSecurity() + return securityAgreement{}, false, nil + } + agreement, err := parseSecurityAgreement(values, session.securityProposal) + if err != nil { + return securityAgreement{}, false, err + } + return agreement, true, nil +} + +func (session *Session) declineSecurity() { + session.securityDeclined = true + session.endpoint = session.initialEndpoint + if session.protectedTCP != nil { + _ = session.protectedTCP.Close() + session.protectedTCP = nil + } + if session.protectedUDP != nil { + _ = session.protectedUDP.Close() + session.protectedUDP = nil + } + session.securityProposal = securityProposal{} +} + +func (session *Session) activateIPSec( + ctx context.Context, + agreement securityAgreement, + ck []byte, + ik []byte, +) error { + if session.securityActive { + return errors.New("ims: ipsec-3gpp is already active") + } + if !session.securityOffered() { + return ErrIPSecAgreementRequired + } + encryptionKey, integrityKey, err := expandIPSecKeys(ck, ik) + if err != nil { + return err + } + defer zeroBytes(encryptionKey) + defer zeroBytes(integrityKey) + + localIP := addressIP(session.conn.LocalAddr()) + remoteIP := addressIP(session.conn.RemoteAddr()) + if localIP == nil || remoteIP == nil { + return errors.New("ims: protected SIP endpoints are unavailable") + } + selected := agreement.selected + config := IPSecSAConfig{ + LocalIP: localIP, + RemoteIP: remoteIP, + UEClientSPI: session.securityProposal.spiClient, + UEServerSPI: session.securityProposal.spiServer, + PCSCFClientSPI: selected.spiClient, + PCSCFServerSPI: selected.spiServer, + UEClientPort: session.securityProposal.portClient, + UEServerPort: session.securityProposal.portServer, + PCSCFClientPort: selected.portClient, + PCSCFServerPort: selected.portServer, + EncryptionKey: encryptionKey, + IntegrityKey: integrityKey, + } + handle, err := session.provider.installer.Install(ctx, config) + if err != nil { + return fmt.Errorf("%w: %v", ErrIPSecInstall, err) + } + if handle == nil { + return fmt.Errorf("%w: installer returned no handle", ErrIPSecInstall) + } + + remoteAddress := net.JoinHostPort(remoteIP.String(), strconv.Itoa(selected.portServer)) + _ = session.conn.Close() + connection, dialErr := dialSIP( + ctx, + session.transport, + localIP.String(), + session.securityProposal.portClient, + remoteAddress, + ) + if dialErr != nil { + cleanupErr := handle.Close(context.Background()) + if cleanupErr != nil { + return errors.Join( + fmt.Errorf("ims: connect protected P-CSCF: %w", dialErr), + fmt.Errorf("ims: roll back ipsec-3gpp: %w", cleanupErr), + ) + } + return fmt.Errorf("ims: connect protected P-CSCF: %w", dialErr) + } + + session.conn = connection + if session.transport == "tcp" { + session.reader = bufio.NewReader(connection) + } else { + session.reader = nil + } + session.endpoint.port = selected.portServer + session.securityAgreement = agreement + session.securityActive = true + session.ipsecHandle = handle + return nil +} + +func (session *Session) contactAddress() string { + if session.securityOffered() { + host := addressHost(session.conn.LocalAddr()) + return net.JoinHostPort(host, strconv.Itoa(session.securityProposal.portServer)) + } + return session.conn.LocalAddr().String() +} + +func (session *Session) emptyDigestAuthorization() string { + uri := "sip:" + session.identity.domain + return "Digest " + strings.Join([]string{ + `username="` + quoteDigest(session.identity.private) + `"`, + `realm="` + quoteDigest(session.identity.domain) + `"`, + `nonce=""`, + `uri="` + quoteDigest(uri) + `"`, + `response=""`, + "algorithm=AKAv1-MD5", + "integrity-protected=no", + }, ", ") +} + +func (session *Session) validProtectedUDPSource(remote *net.UDPAddr) bool { + if remote == nil || !session.securityActive { + return false + } + expectedIP := addressIP(session.conn.RemoteAddr()) + return expectedIP != nil && + expectedIP.Equal(remote.IP) && + remote.Port == session.securityAgreement.selected.portClient +} + +func (session *Session) effectiveSecurityMode() string { + if session.securityActive { + return "ipsec-3gpp" + } + return "none" +} diff --git a/internal/vowifi/ims/security_linux.go b/internal/vowifi/ims/security_linux.go new file mode 100644 index 0000000..7248ec6 --- /dev/null +++ b/internal/vowifi/ims/security_linux.go @@ -0,0 +1,89 @@ +//go:build linux + +package ims + +import ( + "context" + "errors" + "fmt" + "os/exec" + "strings" + "sync" +) + +type linuxIPSecInstaller struct { + ipCommand string +} + +func defaultIPSecInstaller() IPSecSAInstaller { + return linuxIPSecInstaller{ipCommand: "ip"} +} + +type linuxIPSecHandle struct { + mu sync.Mutex + ipCommand string + config IPSecSAConfig + closed bool +} + +func (installer linuxIPSecInstaller) Install(ctx context.Context, config IPSecSAConfig) (IPSecSAHandle, error) { + command := installer.ipCommand + if command == "" { + command = "ip" + } + if _, err := exec.LookPath(command); err != nil { + return nil, errors.New("ims: Linux iproute2 is required for ipsec-3gpp") + } + install, err := buildXFRMInstallPlan(config) + if err != nil { + return nil, err + } + handle := &linuxIPSecHandle{ + ipCommand: command, + config: cloneIPSecSAConfig(config), + } + for _, operation := range install { + if err := runIPCommand(ctx, command, operation); err != nil { + _ = handle.cleanup(context.Background()) + zeroBytes(handle.config.EncryptionKey) + zeroBytes(handle.config.IntegrityKey) + return nil, fmt.Errorf("%w: %v", ErrIPSecInstall, err) + } + } + zeroBytes(handle.config.EncryptionKey) + zeroBytes(handle.config.IntegrityKey) + return handle, nil +} + +func (handle *linuxIPSecHandle) Close(ctx context.Context) error { + handle.mu.Lock() + defer handle.mu.Unlock() + if handle.closed { + return nil + } + handle.closed = true + return handle.cleanup(ctx) +} + +func (handle *linuxIPSecHandle) cleanup(ctx context.Context) error { + var cleanupErrors []error + for _, operation := range buildXFRMCleanupPlan(handle.config) { + if err := runIPCommand(ctx, handle.ipCommand, operation); err != nil { + cleanupErrors = append(cleanupErrors, err) + } + } + return errors.Join(cleanupErrors...) +} + +func runIPCommand(ctx context.Context, command string, operation xfrmOperation) error { + output, err := exec.CommandContext(ctx, command, operation.arguments...).CombinedOutput() + if err == nil { + return nil + } + message := strings.TrimSpace(string(output)) + if message == "" { + message = err.Error() + } + // Operation descriptions contain no SPI keys or subscriber identity. + return fmt.Errorf("%s: %s", operation.description, message) +} diff --git a/internal/vowifi/ims/security_linux_test.go b/internal/vowifi/ims/security_linux_test.go new file mode 100644 index 0000000..da55195 --- /dev/null +++ b/internal/vowifi/ims/security_linux_test.go @@ -0,0 +1,62 @@ +//go:build linux + +package ims + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" +) + +func TestLinuxIPSecInstallerLifecycle(t *testing.T) { + if os.Getenv("VOCAT_NETNS_TEST") != "1" { + t.Skip("set VOCAT_NETNS_TEST=1 inside an isolated Linux network namespace") + } + handle, err := (linuxIPSecInstaller{ipCommand: "ip"}).Install( + context.Background(), + testIPSecSAConfig(), + ) + if err != nil { + t.Fatalf("install ipsec-3gpp XFRM set: %v", err) + } + states, err := exec.Command("ip", "xfrm", "state").CombinedOutput() + if err != nil { + t.Fatalf("list XFRM states: %v: %s", err, states) + } + if count := strings.Count(string(states), "src 10.0.0.2 dst 10.0.0.3"); count != 2 { + t.Fatalf("outbound XFRM state count = %d: %s", count, states) + } + if count := strings.Count(string(states), "src 10.0.0.3 dst 10.0.0.2"); count != 2 { + t.Fatalf("inbound XFRM state count = %d: %s", count, states) + } + policies, err := exec.Command("ip", "xfrm", "policy").CombinedOutput() + if err != nil { + t.Fatalf("list XFRM policies: %v: %s", err, policies) + } + if count := strings.Count(string(policies), "sport 40666 dport 50600"); count != 2 { + t.Fatalf("UE-client policy count = %d: %s", count, policies) + } + + closeContext, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := handle.Close(closeContext); err != nil { + t.Fatalf("close ipsec-3gpp XFRM set: %v", err) + } + states, err = exec.Command("ip", "xfrm", "state").CombinedOutput() + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(states)) != "" { + t.Fatalf("XFRM states survived Close: %s", states) + } + policies, err = exec.Command("ip", "xfrm", "policy").CombinedOutput() + if err != nil { + t.Fatal(err) + } + if strings.TrimSpace(string(policies)) != "" { + t.Fatalf("XFRM policies survived Close: %s", policies) + } +} diff --git a/internal/vowifi/ims/security_other.go b/internal/vowifi/ims/security_other.go new file mode 100644 index 0000000..0a72c06 --- /dev/null +++ b/internal/vowifi/ims/security_other.go @@ -0,0 +1,18 @@ +//go:build !linux + +package ims + +import ( + "context" + "errors" +) + +type unsupportedIPSecInstaller struct{} + +func defaultIPSecInstaller() IPSecSAInstaller { + return unsupportedIPSecInstaller{} +} + +func (unsupportedIPSecInstaller) Install(context.Context, IPSecSAConfig) (IPSecSAHandle, error) { + return nil, errors.New("ims: ipsec-3gpp XFRM installation is supported only on Linux") +} diff --git a/internal/vowifi/ims/security_provider_test.go b/internal/vowifi/ims/security_provider_test.go new file mode 100644 index 0000000..72cade1 --- /dev/null +++ b/internal/vowifi/ims/security_provider_test.go @@ -0,0 +1,570 @@ +package ims + +import ( + "bufio" + "context" + "encoding/base64" + "errors" + "fmt" + "net" + "strconv" + "strings" + "sync" + "testing" + "time" + + "vocat/internal/vowifi" +) + +type fakeIPSecInstaller struct { + mu sync.Mutex + configs []IPSecSAConfig + handle *fakeIPSecHandle +} + +type fakeIPSecHandle struct { + mu sync.Mutex + closeCount int +} + +func (installer *fakeIPSecInstaller) Install( + _ context.Context, + config IPSecSAConfig, +) (IPSecSAHandle, error) { + if err := validateIPSecSAConfig(config); err != nil { + return nil, err + } + installer.mu.Lock() + defer installer.mu.Unlock() + installer.configs = append(installer.configs, cloneIPSecSAConfig(config)) + if installer.handle == nil { + installer.handle = &fakeIPSecHandle{} + } + return installer.handle, nil +} + +func (installer *fakeIPSecInstaller) installed() []IPSecSAConfig { + installer.mu.Lock() + defer installer.mu.Unlock() + result := make([]IPSecSAConfig, 0, len(installer.configs)) + for _, config := range installer.configs { + result = append(result, cloneIPSecSAConfig(config)) + } + return result +} + +func (handle *fakeIPSecHandle) Close(context.Context) error { + handle.mu.Lock() + defer handle.mu.Unlock() + handle.closeCount++ + return nil +} + +func (handle *fakeIPSecHandle) closes() int { + handle.mu.Lock() + defer handle.mu.Unlock() + return handle.closeCount +} + +func TestProviderNegotiatesIPSecAndRegistersOverProtectedTCP(t *testing.T) { + localIP := net.ParseIP("127.0.0.1") + remoteIP := net.ParseIP("127.0.0.2") + initial, err := net.ListenTCP("tcp", &net.TCPAddr{IP: remoteIP}) + if err != nil { + t.Skipf("secondary loopback address is unavailable: %v", err) + } + defer initial.Close() + protected, err := net.ListenTCP("tcp", &net.TCPAddr{IP: remoteIP}) + if err != nil { + t.Fatalf("ListenTCP(protected) error = %v", err) + } + defer protected.Close() + for _, listener := range []*net.TCPListener{initial, protected} { + if err := listener.SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatalf("SetDeadline() error = %v", err) + } + } + + ueClientPort, err := availableProtectedPort(localIP, 0) + if err != nil { + t.Fatalf("availableProtectedPort(client) error = %v", err) + } + ueServerPort, err := availableProtectedPort(localIP, ueClientPort) + if err != nil { + t.Fatalf("availableProtectedPort(server) error = %v", err) + } + pcscfClientPort, err := availableProtectedPort(remoteIP, protected.Addr().(*net.TCPAddr).Port) + if err != nil { + t.Fatalf("availableProtectedPort(P-CSCF client) error = %v", err) + } + + nonceBytes := make([]byte, 32) + for index := range nonceBytes { + nonceBytes[index] = byte(index + 1) + } + nonce := base64.StdEncoding.EncodeToString(nonceBytes) + serverEvidence := make(chan protectedRegistrarEvidence, 1) + serverDone := make(chan error, 1) + go func() { + evidence, err := serveProtectedRegistrar( + initial, + protected, + pcscfClientPort, + nonce, + ueClientPort, + ueServerPort, + ) + if err == nil { + serverEvidence <- evidence + } + serverDone <- err + }() + + installer := &fakeIPSecInstaller{} + aka := &recordingAKA{ + result: vowifi.AKAResult{ + RES: []byte{1, 2, 3, 4, 5, 6, 7, 8}, + CK: []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, + IK: []byte{16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31}, + }, + } + initialAddress := initial.Addr().String() + provider, err := NewProvider(aka, Config{ + PCSCF: initialAddress, + LocalAddress: localIP.String(), + Transport: "tcp", + TransactionTimeout: 3 * time.Second, + SecurityMode: SecurityRequired, + IPSecInstaller: installer, + ProtectedClientPort: ueClientPort, + ProtectedServerPort: ueServerPort, + }) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + session, err := provider.Start(context.Background(), vowifi.IMSRequest{ + DeviceID: "modem0", + Identity: vowifi.SIMIdentity{ + ICCID: "8901000000000000000", + IMSI: "001010123456789", + HomeMCC: "001", + HomeMNC: "01", + }, + Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{ + Established: true, + LocalIPv4: localIP.String(), + PCSCF: []string{initialAddress}, + }}, + }) + if err != nil { + t.Fatalf("Provider.Start() error = %v", err) + } + + evidence := session.Evidence() + if !evidence.Registered || + evidence.RegistrationState != "registered" || + evidence.SecurityMode != "ipsec-3gpp" || + !evidence.SecurityVerified { + t.Fatalf("registration evidence = %#v", evidence) + } + number, source, ok := vowifi.ExtractAssociatedMSISDN(evidence) + if !ok || number != "+8613800138000" || source != vowifi.PhoneSourcePAssociatedURI { + t.Fatalf("ExtractAssociatedMSISDN() = (%q, %q, %t)", number, source, ok) + } + if sms, err := session.EnableSMS(context.Background()); err != nil || !sms.Ready { + t.Fatalf("EnableSMS() = (%#v, %v)", sms, err) + } + + configs := installer.installed() + if len(configs) != 1 { + t.Fatalf("IPsec install count = %d, want 1", len(configs)) + } + config := configs[0] + if !config.LocalIP.Equal(localIP) || + !config.RemoteIP.Equal(remoteIP) || + config.UEClientPort != ueClientPort || + config.UEServerPort != ueServerPort || + config.PCSCFClientPort != pcscfClientPort || + config.PCSCFServerPort != protected.Addr().(*net.TCPAddr).Port { + t.Fatalf("IPsec config endpoints = %#v", config) + } + if got, want := config.EncryptionKey, aka.result.CK; string(got) != string(want) { + t.Fatalf("encryption key = %v, want CK %v", got, want) + } + wantIntegrity := append(append([]byte(nil), aka.result.IK...), 0, 0, 0, 0) + if string(config.IntegrityKey) != string(wantIntegrity) { + t.Fatalf("integrity key = %v, want %v", config.IntegrityKey, wantIntegrity) + } + + if err := session.Close(context.Background()); err != nil { + t.Fatalf("Close() error = %v", err) + } + if err := <-serverDone; err != nil { + t.Fatalf("protected registrar error = %v", err) + } + registrar := <-serverEvidence + if registrar.securityClient == "" || + registrar.securityVerify != registrar.securityServer { + t.Fatalf("security agreement evidence = %#v", registrar) + } + if installer.handle == nil || installer.handle.closes() != 1 { + t.Fatalf("IPsec handle close count = %v", installer.handle) + } +} + +func TestProviderRequiresSecurityServerBeforeAKA(t *testing.T) { + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatalf("ListenUDP() error = %v", err) + } + defer listener.Close() + if err := listener.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + t.Fatalf("SetDeadline() error = %v", err) + } + nonce := base64.StdEncoding.EncodeToString(make([]byte, 32)) + serverDone := make(chan error, 1) + go func() { + packet := make([]byte, 65535) + count, remote, err := listener.ReadFromUDP(packet) + if err != nil { + serverDone <- err + return + } + _, headers, err := parseTestRequest(packet[:count]) + if err != nil { + serverDone <- err + return + } + if headers["security-client"] == "" || + !strings.Contains(headers["authorization"], "integrity-protected=no") { + serverDone <- fmt.Errorf("initial security headers = %#v", headers) + return + } + serverDone <- func() error { + _, err := listener.WriteToUDP(testResponse( + 401, + "Unauthorized", + headers["call-id"], + headers["cseq"], + []string{ + `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, + }, + ), remote) + return err + }() + }() + + aka := &recordingAKA{} + address := listener.LocalAddr().String() + provider, err := NewProvider(aka, Config{ + PCSCF: address, + LocalAddress: "127.0.0.1", + Transport: "udp", + TransactionTimeout: 2 * time.Second, + SecurityMode: SecurityRequired, + IPSecInstaller: &fakeIPSecInstaller{}, + }) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + _, err = provider.Start(context.Background(), vowifi.IMSRequest{ + Identity: vowifi.SIMIdentity{ + IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01", + }, + Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{ + Established: true, + LocalIPv4: "127.0.0.1", + PCSCF: []string{address}, + }}, + }) + if !errors.Is(err, ErrIPSecAgreementRequired) { + t.Fatalf("Provider.Start() error = %v, want ErrIPSecAgreementRequired", err) + } + if len(aka.challenges) != 0 { + t.Fatalf("AKA challenge count = %d, want 0 before a valid security offer", len(aka.challenges)) + } + if err := <-serverDone; err != nil { + t.Fatalf("registrar error = %v", err) + } +} + +func TestProviderRejectsIMSAddressOverridesOutsideTunnelEvidence(t *testing.T) { + identity := vowifi.SIMIdentity{ + IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01", + } + for _, test := range []struct { + name string + config Config + tunnel vowifi.TunnelEvidence + errorMatch string + }{ + { + name: "P-CSCF", + config: Config{ + PCSCF: "127.0.0.1:25000", + LocalAddress: "127.0.0.3", + Transport: "tcp", + SecurityMode: SecurityDisabled, + }, + tunnel: vowifi.TunnelEvidence{ + Established: true, + LocalIPv4: "127.0.0.3", + PCSCF: []string{"127.0.0.2:25000"}, + }, + errorMatch: "P-CSCF", + }, + { + name: "local address", + config: Config{ + PCSCF: "127.0.0.2:25000", + LocalAddress: "127.0.0.3", + Transport: "tcp", + SecurityMode: SecurityDisabled, + }, + tunnel: vowifi.TunnelEvidence{ + Established: true, + LocalIPv4: "127.0.0.4", + PCSCF: []string{"127.0.0.2:25000"}, + }, + errorMatch: "local address", + }, + } { + t.Run(test.name, func(t *testing.T) { + provider, err := NewProvider(&recordingAKA{}, test.config) + if err != nil { + t.Fatalf("NewProvider() error = %v", err) + } + _, err = provider.Start(context.Background(), vowifi.IMSRequest{ + Identity: identity, + Tunnel: evidenceTunnel{evidence: test.tunnel}, + }) + if err == nil || !strings.Contains(err.Error(), test.errorMatch) { + t.Fatalf("Provider.Start() error = %v, want %q", err, test.errorMatch) + } + }) + } +} + +type protectedRegistrarEvidence struct { + securityClient string + securityServer string + securityVerify string +} + +func serveProtectedRegistrar( + initialListener *net.TCPListener, + protectedListener *net.TCPListener, + pcscfClientPort int, + nonce string, + wantUEClientPort int, + wantUEServerPort int, +) (protectedRegistrarEvidence, error) { + var result protectedRegistrarEvidence + initialConnection, err := initialListener.AcceptTCP() + if err != nil { + return result, err + } + _ = initialConnection.SetDeadline(time.Now().Add(5 * time.Second)) + initialReader := bufio.NewReader(initialConnection) + packet, err := readTestTCPRequest(initialReader) + if err != nil { + _ = initialConnection.Close() + return result, err + } + startLine, headers, err := parseTestRequest(packet) + if err != nil { + _ = initialConnection.Close() + return result, err + } + if !strings.HasPrefix(startLine, "REGISTER ") { + _ = initialConnection.Close() + return result, fmt.Errorf("initial method = %q, want REGISTER", startLine) + } + if headers["require"] != "sec-agree" || headers["proxy-require"] != "sec-agree" { + _ = initialConnection.Close() + return result, fmt.Errorf("initial sec-agree headers = %#v", headers) + } + result.securityClient = headers["security-client"] + proposal, err := parseSecurityMechanism(result.securityClient) + if err != nil { + _ = initialConnection.Close() + return result, fmt.Errorf("parse initial Security-Client: %w", err) + } + if proposal.portClient != wantUEClientPort || proposal.portServer != wantUEServerPort { + _ = initialConnection.Close() + return result, fmt.Errorf("UE protected ports = (%d, %d)", proposal.portClient, proposal.portServer) + } + if !strings.Contains(headers["contact"], net.JoinHostPort("127.0.0.1", strconv.Itoa(wantUEServerPort))) { + _ = initialConnection.Close() + return result, fmt.Errorf("initial Contact = %q", headers["contact"]) + } + authDirectives, err := testDigestDirectives(headers["authorization"]) + if err != nil { + _ = initialConnection.Close() + return result, err + } + if authDirectives["nonce"] != "" || + authDirectives["response"] != "" || + authDirectives["integrity-protected"] != "no" { + _ = initialConnection.Close() + return result, fmt.Errorf("initial Authorization = %#v", authDirectives) + } + + pcscfClientSPI, pcscfServerSPI := nonCollidingServerSPIs( + proposal.spiClient, + proposal.spiServer, + ) + result.securityServer = fmt.Sprintf( + "ipsec-3gpp;q=0.100;alg=hmac-sha-1-96;prot=esp;mod=trans;"+ + "ealg=aes-cbc;spi-c=%d;spi-s=%d;port-c=%d;port-s=%d", + pcscfClientSPI, + pcscfServerSPI, + pcscfClientPort, + protectedListener.Addr().(*net.TCPAddr).Port, + ) + callID := headers["call-id"] + if _, err := initialConnection.Write(testResponse( + 401, + "Unauthorized", + callID, + headers["cseq"], + []string{ + `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, + "Security-Server: " + result.securityServer, + }, + )); err != nil { + _ = initialConnection.Close() + return result, err + } + _ = initialConnection.Close() + + protectedConnection, err := protectedListener.AcceptTCP() + if err != nil { + return result, err + } + defer protectedConnection.Close() + if err := protectedConnection.SetDeadline(time.Now().Add(5 * time.Second)); err != nil { + return result, err + } + if got := protectedConnection.RemoteAddr().(*net.TCPAddr).Port; got != wantUEClientPort { + return result, fmt.Errorf("protected TCP source port = %d, want %d", got, wantUEClientPort) + } + protectedReader := bufio.NewReader(protectedConnection) + packet, err = readTestTCPRequest(protectedReader) + if err != nil { + return result, err + } + startLine, headers, err = parseTestRequest(packet) + if err != nil { + return result, err + } + if !strings.HasPrefix(startLine, "REGISTER ") { + return result, fmt.Errorf("protected method = %q, want REGISTER", startLine) + } + if headers["security-client"] != result.securityClient { + return result, fmt.Errorf( + "protected Security-Client = %q, want %q", + headers["security-client"], + result.securityClient, + ) + } + result.securityVerify = headers["security-verify"] + if result.securityVerify != result.securityServer { + return result, fmt.Errorf( + "Security-Verify = %q, want %q", + result.securityVerify, + result.securityServer, + ) + } + if err := verifyTestAuthorization(headers["authorization"], nonce); err != nil { + return result, err + } + protectedAuth, err := testDigestDirectives(headers["authorization"]) + if err != nil { + return result, err + } + if protectedAuth["integrity-protected"] != "yes" { + return result, fmt.Errorf("protected Authorization = %#v", protectedAuth) + } + if !strings.Contains(headers["contact"], net.JoinHostPort("127.0.0.1", strconv.Itoa(wantUEServerPort))) { + return result, fmt.Errorf("protected Contact = %q", headers["contact"]) + } + if strings.Contains(strings.ToUpper(startLine), "MESSAGE") || + strings.Contains(strings.ToUpper(headers["allow"]), "MESSAGE") { + return result, errors.New("registration transaction advertised or sent MESSAGE") + } + + if _, err := protectedConnection.Write(testResponse( + 200, + "OK", + callID, + headers["cseq"], + []string{ + "P-Associated-URI: , ", + "Contact: " + headers["contact"] + ";expires=600", + "Service-Route: ", + }, + )); err != nil { + return result, err + } + + packet, err = readTestTCPRequest(protectedReader) + if err != nil { + return result, err + } + startLine, headers, err = parseTestRequest(packet) + if err != nil { + return result, err + } + if !strings.HasPrefix(startLine, "REGISTER ") || headers["expires"] != "0" { + return result, fmt.Errorf("deregistration request = %q, headers %#v", startLine, headers) + } + if headers["security-verify"] != result.securityServer { + return result, fmt.Errorf("deregistration Security-Verify = %q", headers["security-verify"]) + } + if _, err := protectedConnection.Write( + testResponse(200, "OK", callID, headers["cseq"], nil), + ); err != nil { + return result, err + } + return result, nil +} + +func readTestTCPRequest(reader *bufio.Reader) ([]byte, error) { + var request strings.Builder + for { + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + request.WriteString(line) + if line == "\r\n" || line == "\n" { + return []byte(request.String()), nil + } + if request.Len() > 64*1024 { + return nil, errors.New("SIP request headers are too large") + } + } +} + +func testDigestDirectives(value string) (map[string]string, error) { + scheme, parameters, found := strings.Cut(value, " ") + if !found || !strings.EqualFold(scheme, "Digest") { + return nil, errors.New("Authorization is not Digest") + } + return parseAuthDirectives(parameters) +} + +func nonCollidingServerSPIs(ueClient uint32, ueServer uint32) (uint32, uint32) { + client := uint32(0x70000001) + for client == ueClient || client == ueServer || client == 0 { + client++ + } + server := client + 1 + for server == ueClient || server == ueServer || server == client || server == 0 { + server++ + } + return client, server +} diff --git a/internal/vowifi/ims/security_test.go b/internal/vowifi/ims/security_test.go new file mode 100644 index 0000000..7993f84 --- /dev/null +++ b/internal/vowifi/ims/security_test.go @@ -0,0 +1,234 @@ +package ims + +import ( + "errors" + "net" + "reflect" + "strings" + "testing" +) + +func TestParseSecurityAgreementSelectsSupportedIPSec(t *testing.T) { + proposal := securityProposal{ + spiClient: 1001, + spiServer: 1002, + portClient: 40666, + portServer: 55610, + } + selected := "ipsec-3gpp;q=0.100;alg=hmac-sha-1-96;prot=esp;mod=trans;" + + "ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600" + unsupported := "digest;q=0.900" + agreement, err := parseSecurityAgreement( + []string{unsupported + ", " + selected}, + proposal, + ) + if err != nil { + t.Fatalf("parseSecurityAgreement() error = %v", err) + } + if agreement.selected.spiClient != 2001 || + agreement.selected.spiServer != 2002 || + agreement.selected.portClient != 50601 || + agreement.selected.portServer != 50600 { + t.Fatalf("selected mechanism = %#v", agreement.selected) + } + if agreement.verifyValue != unsupported+", "+selected { + t.Fatalf("Security-Verify = %q", agreement.verifyValue) + } +} + +func TestParseSecurityAgreementFailsClosed(t *testing.T) { + proposal := securityProposal{ + spiClient: 1001, + spiServer: 1002, + portClient: 40666, + portServer: 55610, + } + valid := "ipsec-3gpp;q=0.100;alg=hmac-sha-1-96;prot=esp;mod=trans;" + + "ealg=aes-cbc;spi-c=2001;spi-s=2002;port-c=50601;port-s=50600" + for _, test := range []struct { + name string + values []string + }{ + { + name: "unsupported integrity algorithm", + values: []string{ + strings.Replace(valid, "hmac-sha-1-96", "hmac-md5-96", 1), + }, + }, + { + name: "server SPI collides with UE SPI", + values: []string{ + strings.Replace(valid, "spi-c=2001", "spi-c=1001", 1), + }, + }, + { + name: "server SPIs collide", + values: []string{ + strings.Replace(valid, "spi-s=2002", "spi-s=2001", 1), + }, + }, + { + name: "malformed ipsec offer poisons otherwise valid list", + values: []string{ + valid + ", ipsec-3gpp;q=0.200;alg=hmac-sha-1-96;alg=hmac-sha-1-96", + }, + }, + { + name: "no offer", + values: nil, + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := parseSecurityAgreement(test.values, proposal) + if err == nil { + t.Fatal("parseSecurityAgreement() error = nil") + } + }) + } +} + +func TestExpandIPSecKeys(t *testing.T) { + ck := []byte{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15} + ik := []byte{16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31} + encryption, integrity, err := expandIPSecKeys(ck, ik) + if err != nil { + t.Fatalf("expandIPSecKeys() error = %v", err) + } + if !reflect.DeepEqual(encryption, ck) { + t.Fatalf("encryption key = %v, want %v", encryption, ck) + } + wantIntegrity := append(append([]byte(nil), ik...), 0, 0, 0, 0) + if !reflect.DeepEqual(integrity, wantIntegrity) { + t.Fatalf("integrity key = %v, want %v", integrity, wantIntegrity) + } + encryption[0] ^= 0xff + integrity[0] ^= 0xff + if ck[0] != 0 || ik[0] != 16 { + t.Fatal("expanded keys alias AKA key material") + } +} + +func TestXFRMPlanContainsFourStatesAndProtocolSpecificPolicies(t *testing.T) { + config := testIPSecSAConfig() + install, err := buildXFRMInstallPlan(config) + if err != nil { + t.Fatalf("buildXFRMInstallPlan() error = %v", err) + } + if len(install) != 10 { + t.Fatalf("install operation count = %d, want 10", len(install)) + } + for index, operation := range install[:4] { + if !containsArguments(operation.arguments, "xfrm", "state", "add") { + t.Fatalf("operation %d is not a state add: %v", index, operation.arguments) + } + } + for index, operation := range install[4:] { + if !containsArguments(operation.arguments, "xfrm", "policy", "add") { + t.Fatalf("operation %d is not a policy add: %v", index+4, operation.arguments) + } + } + + clientReqID := argumentAfter(t, install[0].arguments, "reqid") + if got := argumentAfter(t, install[1].arguments, "reqid"); got != clientReqID { + t.Fatalf("client SA pair reqids = %q and %q", clientReqID, got) + } + serverReqID := argumentAfter(t, install[2].arguments, "reqid") + if got := argumentAfter(t, install[3].arguments, "reqid"); got != serverReqID { + t.Fatalf("server SA pair reqids = %q and %q", serverReqID, got) + } + if clientReqID == serverReqID { + t.Fatalf("SA pair reqids both equal %q", clientReqID) + } + + wantPolicies := map[string]bool{ + "tcp 40666 50600 out": false, + "udp 40666 50600 out": false, + "tcp 50600 40666 in": false, + "tcp 50601 55610 in": false, + "udp 50601 55610 in": false, + "tcp 55610 50601 out": false, + } + for _, operation := range install[4:] { + key := strings.Join([]string{ + argumentAfter(t, operation.arguments, "proto"), + argumentAfter(t, operation.arguments, "sport"), + argumentAfter(t, operation.arguments, "dport"), + argumentAfter(t, operation.arguments, "dir"), + }, " ") + if _, expected := wantPolicies[key]; !expected { + t.Fatalf("unexpected policy %q: %v", key, operation.arguments) + } + wantPolicies[key] = true + } + for policy, found := range wantPolicies { + if !found { + t.Errorf("missing policy %q", policy) + } + } + + cleanup := buildXFRMCleanupPlan(config) + if len(cleanup) != 10 { + t.Fatalf("cleanup operation count = %d, want 10", len(cleanup)) + } + keyHex := "0x" + strings.Repeat("11", 16) + for _, operation := range cleanup { + if strings.Contains(strings.Join(operation.arguments, " "), keyHex) { + t.Fatalf("cleanup operation retained encryption key: %v", operation.arguments) + } + } +} + +func TestValidateIPSecSAConfigRejectsDuplicateSPI(t *testing.T) { + config := testIPSecSAConfig() + config.PCSCFServerSPI = config.UEClientSPI + if err := validateIPSecSAConfig(config); err == nil { + t.Fatal("validateIPSecSAConfig() error = nil") + } +} + +func testIPSecSAConfig() IPSecSAConfig { + return IPSecSAConfig{ + LocalIP: net.ParseIP("10.0.0.2"), + RemoteIP: net.ParseIP("10.0.0.3"), + UEClientSPI: 0x10000001, + UEServerSPI: 0x10000002, + PCSCFClientSPI: 0x20000001, + PCSCFServerSPI: 0x20000002, + UEClientPort: 40666, + UEServerPort: 55610, + PCSCFClientPort: 50601, + PCSCFServerPort: 50600, + EncryptionKey: []byte(strings.Repeat("\x11", 16)), + IntegrityKey: []byte(strings.Repeat("\x22", 20)), + } +} + +func argumentAfter(t *testing.T, arguments []string, name string) string { + t.Helper() + for index := 0; index+1 < len(arguments); index++ { + if arguments[index] == name { + return arguments[index+1] + } + } + t.Fatalf("arguments %v omit %q", arguments, name) + return "" +} + +func containsArguments(arguments []string, sequence ...string) bool { + if len(sequence) == 0 || len(sequence) > len(arguments) { + return false + } + for start := 0; start+len(sequence) <= len(arguments); start++ { + if reflect.DeepEqual(arguments[start:start+len(sequence)], sequence) { + return true + } + } + return false +} + +func TestErrorsExposeAgreementSentinel(t *testing.T) { + _, err := parseSecurityAgreement(nil, securityProposal{}) + if !errors.Is(err, ErrIPSecAgreementRequired) { + t.Fatalf("error = %v, want ErrIPSecAgreementRequired", err) + } +} diff --git a/internal/vowifi/ims/sms_runtime.go b/internal/vowifi/ims/sms_runtime.go new file mode 100644 index 0000000..d02b0a0 --- /dev/null +++ b/internal/vowifi/ims/sms_runtime.go @@ -0,0 +1,723 @@ +package ims + +import ( + "bufio" + "context" + "encoding/hex" + "errors" + "fmt" + "net" + "strconv" + "strings" + "time" + + "vocat/internal/device" + "vocat/internal/vowifi" +) + +const smsContentType = "application/vnd.3gpp.sms" + +var ( + ErrSMSCUnavailable = errors.New("ims: SMS service-centre address is unavailable") + ErrSMSRejected = errors.New("ims: SMS MESSAGE was rejected") +) + +type smsCenterReader interface { + ReadSMSCenter(context.Context, string) (string, error) +} + +// ReceivedSMS is a decoded mobile-terminated SMS delivered over IMS. +type ReceivedSMS struct { + MessageID string + DeviceID string + IMSI string + From string + Text string + Timestamp time.Time + ServiceCenterTimestamp *time.Time + Encoding device.SMSEncoding + Concat *device.SMSConcatInfo + RPReference int + CallID string + RawRPDU string + RawTPDU string +} + +// ReceivedSMSStatus is network delivery evidence for one submitted SMS part. +type ReceivedSMSStatus struct { + DeviceID string + IMSI string + To string + MessageReference int + StatusCode int + DeliveryStatus string + ServiceCenterTimestamp *time.Time + DischargeTimestamp *time.Time + Timestamp time.Time + RPReference int + CallID string + RawRPDU string + RawTPDU string +} + +type sipTransactionKey struct { + callID string + cseq uint32 + method string +} + +func (session *Session) startRuntimeReceivers() error { + if session.runtimeStarted { + return nil + } + if err := session.conn.SetDeadline(time.Time{}); err != nil { + return fmt.Errorf("ims: clear SIP connection deadline: %w", err) + } + if session.protectedUDP != nil { + _ = session.protectedUDP.SetReadDeadline(time.Time{}) + } + session.runtimeStarted = true + + session.receiveDone.Add(1) + go session.readMainConnection() + if session.securityActive && session.transport == "tcp" && session.protectedTCP != nil { + session.receiveDone.Add(1) + go session.acceptProtectedTCP() + } + if session.securityActive && session.transport == "udp" && session.protectedUDP != nil { + session.receiveDone.Add(1) + go session.readProtectedUDP() + } + return nil +} + +func (session *Session) readMainConnection() { + defer session.receiveDone.Done() + for { + var packet sipPacket + var err error + if session.transport == "tcp" { + packet, err = readSIPPacket(session.reader) + } else { + buffer := make([]byte, 65535) + var count int + count, err = session.conn.Read(buffer) + if err == nil { + packet, err = parseSIPPacket(buffer[:count]) + } + } + if err != nil { + if !session.isClosed() { + session.publishFailure(fmt.Errorf("ims: SIP receive loop: %w", err)) + } + return + } + session.dispatchPacket(packet, func(response []byte) error { + session.writeMu.Lock() + defer session.writeMu.Unlock() + _, err := session.conn.Write(response) + return err + }) + } +} + +func (session *Session) acceptProtectedTCP() { + defer session.receiveDone.Done() + for { + connection, err := session.protectedTCP.AcceptTCP() + if err != nil { + return + } + if !session.validProtectedTCPSource(connection.RemoteAddr()) { + _ = connection.Close() + continue + } + session.inboundMu.Lock() + session.inboundConnections[connection] = struct{}{} + session.inboundMu.Unlock() + session.receiveDone.Add(1) + go session.readInboundTCP(connection) + } +} + +func (session *Session) readInboundTCP(connection net.Conn) { + defer session.receiveDone.Done() + defer func() { + session.inboundMu.Lock() + delete(session.inboundConnections, connection) + session.inboundMu.Unlock() + _ = connection.Close() + }() + reader := bufio.NewReader(connection) + for { + packet, err := readSIPPacket(reader) + if err != nil { + return + } + session.dispatchPacket(packet, func(response []byte) error { + _, err := connection.Write(response) + return err + }) + } +} + +func (session *Session) readProtectedUDP() { + defer session.receiveDone.Done() + buffer := make([]byte, 65535) + for { + count, remote, err := session.protectedUDP.ReadFromUDP(buffer) + if err != nil { + return + } + if !session.validProtectedUDPSource(remote) { + continue + } + packet, err := parseSIPPacket(buffer[:count]) + if err != nil { + continue + } + session.dispatchPacket(packet, func(response []byte) error { + _, err := session.protectedUDP.WriteToUDP(response, remote) + return err + }) + } +} + +func (session *Session) validProtectedTCPSource(address net.Addr) bool { + remote, ok := address.(*net.TCPAddr) + if !ok || !session.securityActive { + return false + } + expected := addressIP(session.conn.RemoteAddr()) + return expected != nil && expected.Equal(remote.IP) && + remote.Port == session.securityAgreement.selected.portClient +} + +func (session *Session) dispatchPacket(packet sipPacket, respond func([]byte) error) { + if packet.Response != nil { + response := packet.Response + cseq, method, err := cseqNumber(response.value("CSeq")) + if err != nil { + return + } + key := sipTransactionKey{ + callID: strings.TrimSpace(response.value("Call-ID")), + cseq: cseq, + method: method, + } + session.transactionsMu.Lock() + channel := session.transactions[key] + session.transactionsMu.Unlock() + if channel != nil { + select { + case channel <- response: + default: + } + } + return + } + if packet.Request != nil { + session.handleSIPRequest(packet.Request, respond) + } +} + +func (session *Session) exchangeRuntime( + ctx context.Context, + request []byte, + key sipTransactionKey, +) (*sipResponse, error) { + responses := make(chan *sipResponse, 4) + session.transactionsMu.Lock() + if _, duplicate := session.transactions[key]; duplicate { + session.transactionsMu.Unlock() + return nil, errors.New("ims: duplicate SIP transaction") + } + session.transactions[key] = responses + session.transactionsMu.Unlock() + defer func() { + session.transactionsMu.Lock() + delete(session.transactions, key) + session.transactionsMu.Unlock() + }() + + session.writeMu.Lock() + _, err := session.conn.Write(request) + session.writeMu.Unlock() + if err != nil { + return nil, fmt.Errorf("ims: send SIP %s: %w", key.method, err) + } + timer := time.NewTimer(session.provider.config.TransactionTimeout) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + return nil, fmt.Errorf("ims: SIP %s transaction timed out", key.method) + case response := <-responses: + if response.StatusCode >= 100 && response.StatusCode < 200 { + continue + } + return response, nil + } + } +} + +func (session *Session) handleSIPRequest(request *sipRequest, respond func([]byte) error) { + status := 200 + switch request.Method { + case "OPTIONS": + case "MESSAGE": + contentType := strings.ToLower(strings.TrimSpace(strings.SplitN(request.value("Content-Type"), ";", 2)[0])) + if contentType != smsContentType { + status = 415 + } + default: + status = 405 + } + response, err := buildSIPResponse(request, status, session.fromTag) + if err == nil { + _ = respond(response) + } + if status != 200 || request.Method != "MESSAGE" { + return + } + go session.processSMSMessage(request) +} + +func buildSIPResponse(request *sipRequest, status int, tag string) ([]byte, error) { + reason := map[int]string{200: "OK", 405: "Method Not Allowed", 415: "Unsupported Media Type", 488: "Not Acceptable Here"}[status] + if reason == "" { + return nil, errors.New("ims: unsupported SIP response status") + } + via := request.values("Via") + from := request.value("From") + to := request.value("To") + callID := request.value("Call-ID") + cseq := request.value("CSeq") + if len(via) == 0 || from == "" || to == "" || callID == "" || cseq == "" { + return nil, errors.New("ims: request omitted a mandatory response header") + } + if !strings.Contains(strings.ToLower(to), ";tag=") { + to += ";tag=" + tag + } + lines := []string{fmt.Sprintf("SIP/2.0 %d %s", status, reason)} + for _, value := range via { + lines = append(lines, "Via: "+value) + } + lines = append(lines, + "From: "+from, + "To: "+to, + "Call-ID: "+callID, + "CSeq: "+cseq, + ) + if status == 405 { + lines = append(lines, "Allow: REGISTER, MESSAGE, OPTIONS") + } + if status == 415 { + lines = append(lines, "Accept: "+smsContentType) + } + lines = append(lines, "Content-Length: 0", "", "") + return []byte(strings.Join(lines, "\r\n")), nil +} + +func (session *Session) processSMSMessage(request *sipRequest) { + rpdu, err := parseRPDU(request.Body) + if err != nil { + session.sendDeliveryReport(request, buildRPError(0, 95)) + return + } + if rpdu.messageType != 1 { // RP-DATA, network to MS. + return + } + message, err := device.DecodeSMSDeliverTPDU(rpdu.tpdu) + if err != nil { + session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95)) + return + } + receivedAt := time.Now().UTC() + callID := strings.TrimSpace(request.value("Call-ID")) + if message.Direction == device.SMSDirectionStatusReport { + if message.MessageReference == nil || message.StatusCode == nil { + session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95)) + return + } + status := ReceivedSMSStatus{ + DeviceID: session.request.DeviceID, + IMSI: session.request.Identity.IMSI, + To: message.To, + MessageReference: *message.MessageReference, + StatusCode: *message.StatusCode, + DeliveryStatus: message.DeliveryStatus, + ServiceCenterTimestamp: message.ServiceCenterTimestamp, + DischargeTimestamp: message.DischargeTimestamp, + Timestamp: receivedAt, + RPReference: int(rpdu.reference), + CallID: callID, + RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)), + RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)), + } + if session.provider.config.OnSMSStatus != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err = session.provider.config.OnSMSStatus(ctx, status) + cancel() + } + if err != nil { + session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22)) + return + } + session.sendDeliveryReport(request, []byte{0x02, rpdu.reference}) + return + } + if message.Direction != device.SMSDirectionReceived { + session.sendDeliveryReport(request, buildRPError(rpdu.reference, 95)) + return + } + var serviceCenterTimestamp *time.Time + if message.ServiceCenterTimestamp != nil { + value := message.ServiceCenterTimestamp.UTC() + serviceCenterTimestamp = &value + } + received := ReceivedSMS{ + // A retransmission inside the same SIP transaction is idempotent, but a + // fresh Call-ID/RP reference is a distinct network delivery and must stay + // visible even when its TPDU and text happen to be identical. + MessageID: fmt.Sprintf("ims:%s:%d", callID, rpdu.reference), + DeviceID: session.request.DeviceID, + IMSI: session.request.Identity.IMSI, + From: message.From, + Text: message.Text, + Timestamp: receivedAt, + ServiceCenterTimestamp: serviceCenterTimestamp, + Encoding: message.Encoding, + Concat: message.Concat, + RPReference: int(rpdu.reference), + CallID: callID, + RawRPDU: strings.ToUpper(hex.EncodeToString(request.Body)), + RawTPDU: strings.ToUpper(hex.EncodeToString(rpdu.tpdu)), + } + if session.provider.config.OnSMS != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + err = session.provider.config.OnSMS(ctx, received) + cancel() + } + if err != nil { + session.sendDeliveryReport(request, buildRPError(rpdu.reference, 22)) + return + } + session.sendDeliveryReport(request, []byte{0x02, rpdu.reference}) +} + +func (session *Session) sendDeliveryReport(request *sipRequest, report []byte) { + target := firstURI(request.value("P-Asserted-Identity")) + if target == "" { + target = firstURI(request.value("From")) + } + if target == "" { + return + } + _, _ = session.sendSIPMessage( + context.Background(), + target, + report, + strings.TrimSpace(request.value("Call-ID")), + ) +} + +func (session *Session) SendSMS(ctx context.Context, request vowifi.SMSSubmitRequest) (vowifi.SMSSubmitResult, error) { + if ctx == nil { + ctx = context.Background() + } + session.smsMu.Lock() + defer session.smsMu.Unlock() + + session.mu.Lock() + if session.closed || !session.evidence.Registered || !session.smsContactConfirmed { + session.mu.Unlock() + return vowifi.SMSSubmitResult{}, vowifi.ErrSMSNotReady + } + smsc := strings.TrimSpace(session.request.Identity.SMSC) + session.mu.Unlock() + if smsc == "" { + reader, ok := session.provider.aka.(smsCenterReader) + var readErr error + if ok { + smsc, readErr = reader.ReadSMSCenter(ctx, session.request.DeviceID) + } + if strings.TrimSpace(smsc) == "" { + smsc = session.provider.config.SMSCenter + } + if strings.TrimSpace(smsc) == "" { + return vowifi.SMSSubmitResult{}, errors.Join(ErrSMSCUnavailable, readErr) + } + session.mu.Lock() + session.request.Identity.SMSC = smsc + session.mu.Unlock() + } + parts, err := device.PrepareSMSSubmitTPDUs(request.Recipient, request.Text) + if err != nil { + return vowifi.SMSSubmitResult{}, err + } + now := time.Now().UTC() + result := vowifi.SMSSubmitResult{ + To: parts[0].To, + Encoding: string(parts[0].Encoding), + SubmittedAt: now, + PartsTotal: len(parts), + ConcatReference: parts[0].ConcatReference, + SubmissionStatus: "pending", + PartResults: make([]vowifi.SMSSubmitPart, 0, len(parts)), + } + psi := "tel:" + normalizeE164(smsc) + for _, part := range parts { + reference := session.allocateRPReference() + if len(part.TPDU) < 2 { + return result, errors.New("ims: SMS-SUBMIT TPDU is truncated") + } + // Use the same value for TP-MR and RP-Message-Reference so an + // SMS-STATUS-REPORT can be mapped back to this submitted part. + part.TPDU[1] = reference + rpdu, buildErr := buildRPData(reference, smsc, part.TPDU) + if buildErr != nil { + return result, buildErr + } + result.PartsAttempted++ + response, sendErr := session.sendSIPMessage(ctx, psi, rpdu, "") + partResult := vowifi.SMSSubmitPart{ + Part: part.Part, Total: part.Total, Reference: int(reference), SubmittedAt: time.Now().UTC(), + } + if response != nil { + partResult.SIPCode = response.StatusCode + } + if sendErr == nil && response.StatusCode >= 200 && response.StatusCode < 300 { + partResult.Accepted = true + partResult.SubmissionStatus = "accepted_by_ims" + result.PartsAccepted++ + } else { + partResult.SubmissionStatus = "rejected_by_ims" + } + result.PartResults = append(result.PartResults, partResult) + if sendErr != nil { + result.SubmissionStatus = "failed" + return result, sendErr + } + if !partResult.Accepted { + result.SubmissionStatus = "rejected" + return result, fmt.Errorf("%w: SIP %d", ErrSMSRejected, response.StatusCode) + } + } + result.AllPartsAccepted = true + result.SubmissionStatus = "accepted_by_ims" + return result, nil +} + +func (session *Session) allocateRPReference() byte { + session.mu.Lock() + defer session.mu.Unlock() + value := session.nextRPReference + session.nextRPReference++ + return value +} + +func (session *Session) sendSIPMessage( + ctx context.Context, + target string, + body []byte, + inReplyTo string, +) (*sipResponse, error) { + callToken, err := randomHex(18) + if err != nil { + return nil, err + } + branch, err := randomHex(12) + if err != nil { + return nil, err + } + callID := callToken + "@" + addressHost(session.conn.LocalAddr()) + session.mu.Lock() + cseq := session.cseq + session.cseq++ + serviceRoutes := append([]string(nil), session.evidence.ServiceRoute...) + securityHeaders := runtimeSecurityHeaders( + session.securityActive, + session.securityAgreement.verifyValue, + ) + session.mu.Unlock() + transportUpper := strings.ToUpper(session.transport) + lines := []string{ + "MESSAGE " + target + " SIP/2.0", + fmt.Sprintf("Via: SIP/2.0/%s %s;branch=z9hG4bK%s;rport", transportUpper, session.conn.LocalAddr().String(), branch), + "Max-Forwards: 70", + } + lines = append(lines, securityHeaders...) + if len(serviceRoutes) == 0 { + lines = append(lines, "Route: ") + } else { + for _, route := range serviceRoutes { + lines = append(lines, "Route: "+route) + } + } + lines = append(lines, + "From: <"+session.identity.public+">;tag="+session.fromTag, + "To: <"+target+">", + "Call-ID: "+callID, + fmt.Sprintf("CSeq: %d MESSAGE", cseq), + "P-Preferred-Identity: <"+session.identity.public+">", + "Accept-Contact: *;+g.3gpp.smsip", + ) + if inReplyTo != "" { + lines = append(lines, "In-Reply-To: "+inReplyTo) + } + lines = append(lines, + "Content-Type: "+smsContentType, + "Content-Transfer-Encoding: binary", + "Content-Length: "+strconv.Itoa(len(body)), + "", "", + ) + request := append([]byte(strings.Join(lines, "\r\n")), body...) + return session.exchangeRuntime(ctx, request, sipTransactionKey{callID: callID, cseq: cseq, method: "MESSAGE"}) +} + +func runtimeSecurityHeaders(active bool, verifyValue string) []string { + verifyValue = strings.TrimSpace(verifyValue) + if !active || verifyValue == "" { + return nil + } + // RFC 3329 requires every request following a security agreement to + // mirror Security-Server and repeat both sec-agree option tags. Omitting + // these fields causes Vodafone's P-CSCF to reject MESSAGE with SIP 494. + return []string{ + "Security-Verify: " + verifyValue, + "Require: sec-agree", + "Proxy-Require: sec-agree", + } +} + +type rpMessage struct { + messageType byte + reference byte + tpdu []byte +} + +func parseRPDU(data []byte) (rpMessage, error) { + if len(data) < 2 { + return rpMessage{}, errors.New("ims: RPDU is truncated") + } + result := rpMessage{messageType: data[0] & 0x07, reference: data[1]} + if result.messageType != 1 { + return result, nil + } + index := 2 + for count := 0; count < 2; count++ { + if index >= len(data) { + return rpMessage{}, errors.New("ims: RP-DATA address is truncated") + } + length := int(data[index]) + index++ + if length > len(data)-index { + return rpMessage{}, errors.New("ims: RP-DATA address length is invalid") + } + index += length + } + if index >= len(data) { + return rpMessage{}, errors.New("ims: RP-DATA omitted user data") + } + length := int(data[index]) + index++ + if length == 0 || length > len(data)-index { + return rpMessage{}, errors.New("ims: RP-DATA user-data length is invalid") + } + result.tpdu = append([]byte(nil), data[index:index+length]...) + return result, nil +} + +func buildRPData(reference byte, smsc string, tpdu []byte) ([]byte, error) { + address, err := encodeRPAddress(smsc) + if err != nil { + return nil, err + } + if len(tpdu) == 0 || len(tpdu) > 232 { + return nil, errors.New("ims: SMS TPDU length is invalid") + } + result := []byte{0x00, reference, 0x00, byte(len(address))} + result = append(result, address...) + result = append(result, byte(len(tpdu))) + result = append(result, tpdu...) + return result, nil +} + +func buildRPError(reference byte, cause byte) []byte { + return []byte{0x04, reference, 0x01, cause & 0x7f} +} + +func encodeRPAddress(value string) ([]byte, error) { + value = normalizeE164(value) + digits := strings.TrimPrefix(value, "+") + if len(digits) < 3 || len(digits) > 20 { + return nil, ErrSMSCUnavailable + } + toa := byte(0x81) + if strings.HasPrefix(value, "+") { + toa = 0x91 + } + encoded := make([]byte, (len(digits)+1)/2) + for index := 0; index < len(digits); index += 2 { + if digits[index] < '0' || digits[index] > '9' { + return nil, ErrSMSCUnavailable + } + low := digits[index] - '0' + high := byte(0x0f) + if index+1 < len(digits) { + if digits[index+1] < '0' || digits[index+1] > '9' { + return nil, ErrSMSCUnavailable + } + high = digits[index+1] - '0' + } + encoded[index/2] = high<<4 | low + } + return append([]byte{toa}, encoded...), nil +} + +func normalizeE164(value string) string { + value = strings.TrimSpace(value) + var result strings.Builder + for index, character := range value { + if character >= '0' && character <= '9' || (index == 0 && character == '+') { + result.WriteRune(character) + } + } + return result.String() +} + +func firstURI(value string) string { + value = strings.TrimSpace(strings.SplitN(value, ",", 2)[0]) + if start := strings.IndexByte(value, '<'); start >= 0 { + if end := strings.IndexByte(value[start+1:], '>'); end >= 0 { + return strings.TrimSpace(value[start+1 : start+1+end]) + } + } + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + value = value[:semicolon] + } + return strings.TrimSpace(value) +} + +func (session *Session) isClosed() bool { + session.mu.Lock() + defer session.mu.Unlock() + return session.closed +} + +func (session *Session) closeInboundConnections() { + session.inboundMu.Lock() + connections := make([]net.Conn, 0, len(session.inboundConnections)) + for connection := range session.inboundConnections { + connections = append(connections, connection) + } + session.inboundMu.Unlock() + for _, connection := range connections { + _ = connection.Close() + } +} + +var _ vowifi.SMSSender = (*Session)(nil) diff --git a/internal/vowifi/ims/sms_runtime_test.go b/internal/vowifi/ims/sms_runtime_test.go new file mode 100644 index 0000000..8b3be97 --- /dev/null +++ b/internal/vowifi/ims/sms_runtime_test.go @@ -0,0 +1,372 @@ +package ims + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" + + "vocat/internal/vowifi" +) + +type smsTestAKA struct{ *recordingAKA } + +func (smsTestAKA) ReadSMSCenter(context.Context, string) (string, error) { + return "+447785016005", nil +} + +func TestSessionReceivesAndAcknowledgesSMSOverIMS(t *testing.T) { + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + _ = listener.SetDeadline(time.Now().Add(10 * time.Second)) + + received := make(chan ReceivedSMS, 1) + serverDone := make(chan error, 1) + nonce := base64.StdEncoding.EncodeToString(make([]byte, 32)) + go func() { serverDone <- serveInboundSMS(listener, nonce) }() + provider, err := NewProvider( + smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}}, + Config{ + PCSCF: listener.LocalAddr().String(), LocalAddress: "127.0.0.1", + Transport: "udp", TransactionTimeout: 3 * time.Second, SecurityMode: SecurityDisabled, + OnSMS: func(_ context.Context, message ReceivedSMS) error { + received <- message + return nil + }, + }, + ) + if err != nil { + t.Fatal(err) + } + session, err := provider.Start(context.Background(), vowifi.IMSRequest{ + DeviceID: "ec20", + Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"}, + Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{ + Established: true, LocalIPv4: "127.0.0.1", PCSCF: []string{listener.LocalAddr().String()}, + }}, + }) + if err != nil { + t.Fatal(err) + } + select { + case message := <-received: + if message.From != "+12345" || message.Text != "HELLO" || + message.MessageID != "ims:network-deliver-1:42" || + message.ServiceCenterTimestamp == nil || message.Timestamp.IsZero() { + t.Fatalf("received = %#v", message) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for inbound SMS") + } + if err := session.Close(context.Background()); err != nil { + t.Fatal(err) + } + if err := <-serverDone; err != nil { + t.Fatal(err) + } +} + +func TestRuntimeSecurityHeaders(t *testing.T) { + verify := "ipsec-3gpp;alg=hmac-sha-1-96;prot=esp;mod=trans" + headers := runtimeSecurityHeaders(true, verify) + want := []string{ + "Security-Verify: " + verify, + "Require: sec-agree", + "Proxy-Require: sec-agree", + } + if len(headers) != len(want) { + t.Fatalf("security header count = %d, want %d", len(headers), len(want)) + } + for index := range want { + if headers[index] != want[index] { + t.Fatalf("security header %d = %q, want %q", index, headers[index], want[index]) + } + } + if headers := runtimeSecurityHeaders(false, verify); len(headers) != 0 { + t.Fatalf("disabled security headers = %#v", headers) + } +} + +func TestSessionSendsSMSOverIMS(t *testing.T) { + listener, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + defer listener.Close() + _ = listener.SetDeadline(time.Now().Add(10 * time.Second)) + serverDone := make(chan error, 1) + statusReceived := make(chan ReceivedSMSStatus, 1) + nonce := base64.StdEncoding.EncodeToString(make([]byte, 32)) + go func() { serverDone <- serveOutboundSMS(listener, nonce) }() + provider, err := NewProvider( + smsTestAKA{&recordingAKA{result: vowifi.AKAResult{RES: []byte{1, 2, 3, 4}}}}, + Config{ + PCSCF: listener.LocalAddr().String(), LocalAddress: "127.0.0.1", + Transport: "udp", TransactionTimeout: 3 * time.Second, SecurityMode: SecurityDisabled, + OnSMSStatus: func(_ context.Context, status ReceivedSMSStatus) error { + statusReceived <- status + return nil + }, + }, + ) + if err != nil { + t.Fatal(err) + } + session, err := provider.Start(context.Background(), vowifi.IMSRequest{ + DeviceID: "ec20", + Identity: vowifi.SIMIdentity{IMSI: "001010123456789", HomeMCC: "001", HomeMNC: "01"}, + Tunnel: evidenceTunnel{evidence: vowifi.TunnelEvidence{ + Established: true, LocalIPv4: "127.0.0.1", PCSCF: []string{listener.LocalAddr().String()}, + }}, + }) + if err != nil { + t.Fatal(err) + } + result, err := session.(vowifi.SMSSender).SendSMS(context.Background(), vowifi.SMSSubmitRequest{ + Recipient: "+12345", Text: "HELLO", + }) + if err != nil || !result.AllPartsAccepted || result.PartsAccepted != 1 || result.PartResults[0].SIPCode != 202 { + t.Fatalf("SendSMS = (%#v, %v)", result, err) + } + select { + case status := <-statusReceived: + if status.To != "+12345" || status.MessageReference != result.PartResults[0].Reference || + status.StatusCode != 0 || status.DeliveryStatus != "delivered" || + status.ServiceCenterTimestamp == nil || status.DischargeTimestamp == nil { + t.Fatalf("SMS status = %#v", status) + } + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for SMS delivery status") + } + if err := session.Close(context.Background()); err != nil { + t.Fatal(err) + } + if err := <-serverDone; err != nil { + t.Fatal(err) + } +} + +func serveInboundSMS(listener *net.UDPConn, nonce string) error { + packet := make([]byte, 65535) + count, remote, err := listener.ReadFromUDP(packet) + if err != nil { + return err + } + _, headers, err := parseTestRequest(packet[:count]) + if err != nil { + return err + } + callID := headers["call-id"] + if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", callID, headers["cseq"], []string{ + `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, + }), remote); err != nil { + return err + } + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + _, headers, err = parseTestRequest(packet[:count]) + if err != nil { + return err + } + if _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], []string{ + "Contact: " + headers["contact"] + ";expires=600", + }), remote); err != nil { + return err + } + + tpdu := []byte{ + 0x04, 0x05, 0x91, 0x21, 0x43, 0xf5, 0x00, 0x00, + 0x42, 0x10, 0x20, 0x30, 0x40, 0x50, 0x00, 0x05, + 0xc8, 0x22, 0x93, 0xf9, 0x04, + } + rpdu := []byte{0x01, 0x2a, 0x00, 0x00, byte(len(tpdu))} + rpdu = append(rpdu, tpdu...) + request := []byte(strings.Join([]string{ + "MESSAGE sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0", + "Via: SIP/2.0/UDP " + listener.LocalAddr().String() + ";branch=z9hG4bKdeliver", + "From: ;tag=gw", + "To: ", + "P-Asserted-Identity: ", + "Call-ID: network-deliver-1", + "CSeq: 1 MESSAGE", + "Content-Type: application/vnd.3gpp.sms", + fmt.Sprintf("Content-Length: %d", len(rpdu)), "", "", + }, "\r\n")) + request = append(request, rpdu...) + if _, err = listener.WriteToUDP(request, remote); err != nil { + return err + } + + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + response, err := parseSIPResponse(packet[:count]) + if err != nil || response.StatusCode != 200 { + return fmt.Errorf("delivery SIP response = (%#v, %v)", response, err) + } + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + report, err := parseSIPPacket(packet[:count]) + if err != nil || report.Request == nil { + return fmt.Errorf("delivery report parse: %v", err) + } + if report.Request.Method != "MESSAGE" || report.Request.value("In-Reply-To") != "network-deliver-1" || + len(report.Request.Body) != 2 || report.Request.Body[0] != 0x02 || report.Request.Body[1] != 0x2a { + return fmt.Errorf("unexpected delivery report %#v", report.Request) + } + if _, err = listener.WriteToUDP(testResponse(200, "OK", report.Request.value("Call-ID"), report.Request.value("CSeq"), nil), remote); err != nil { + return err + } + + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + _, headers, err = parseTestRequest(packet[:count]) + if err != nil { + return err + } + if headers["expires"] != "0" { + return errors.New("expected deregistration") + } + _, err = listener.WriteToUDP(testResponse(200, "OK", callID, headers["cseq"], nil), remote) + return err +} + +func serveOutboundSMS(listener *net.UDPConn, nonce string) error { + packet := make([]byte, 65535) + count, remote, err := listener.ReadFromUDP(packet) + if err != nil { + return err + } + _, headers, err := parseTestRequest(packet[:count]) + if err != nil { + return err + } + registerCallID := headers["call-id"] + if _, err = listener.WriteToUDP(testResponse(401, "Unauthorized", registerCallID, headers["cseq"], []string{ + `WWW-Authenticate: Digest realm="ims.mnc001.mcc001.3gppnetwork.org", nonce="` + nonce + `", algorithm=AKAv1-MD5, qop="auth"`, + }), remote); err != nil { + return err + } + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + _, headers, err = parseTestRequest(packet[:count]) + if err != nil { + return err + } + if _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], []string{ + "Contact: " + headers["contact"] + ";expires=600", + }), remote); err != nil { + return err + } + + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + message, err := parseSIPPacket(packet[:count]) + if err != nil || message.Request == nil { + return fmt.Errorf("outbound MESSAGE parse: %v", err) + } + if message.Request.Method != "MESSAGE" || message.Request.URI != "tel:+447785016005" || + strings.ToLower(message.Request.value("Content-Type")) != smsContentType { + return fmt.Errorf("unexpected outbound MESSAGE %#v", message.Request) + } + rpdu, err := parseRPDU(message.Request.Body) + if err != nil || rpdu.messageType != 0 || len(rpdu.tpdu) != 0 { + // parseRPDU intentionally decodes only network-to-MS RP-DATA; inspect + // the mandatory MO prefix and TPDU length directly below. + if err != nil { + return err + } + } + body := message.Request.Body + if len(body) < 8 || body[0] != 0x00 || body[2] != 0x00 { + return fmt.Errorf("invalid MO RP-DATA %x", body) + } + destinationLength := int(body[3]) + userLengthIndex := 4 + destinationLength + if userLengthIndex >= len(body) || int(body[userLengthIndex]) != len(body)-userLengthIndex-1 { + return fmt.Errorf("invalid MO RP-DATA lengths %x", body) + } + tpdu := body[userLengthIndex+1:] + if len(tpdu) < 2 || tpdu[0]&0x03 != 1 || tpdu[0]&0x20 == 0 || tpdu[1] != body[1] { + return fmt.Errorf("SMS-SUBMIT did not request a trackable status report: %x", tpdu) + } + if _, err = listener.WriteToUDP(testResponse(202, "Accepted", message.Request.value("Call-ID"), message.Request.value("CSeq"), nil), remote); err != nil { + return err + } + + statusTPDU := []byte{ + 0x02, tpdu[1], 0x05, 0x91, 0x21, 0x43, 0xf5, + 0x42, 0x10, 0x20, 0x30, 0x40, 0x50, 0x00, + 0x42, 0x10, 0x20, 0x30, 0x50, 0x50, 0x00, + 0x00, + } + statusRPDU := []byte{0x01, 0x2b, 0x00, 0x00, byte(len(statusTPDU))} + statusRPDU = append(statusRPDU, statusTPDU...) + statusRequest := []byte(strings.Join([]string{ + "MESSAGE sip:001010123456789@ims.mnc001.mcc001.3gppnetwork.org SIP/2.0", + "Via: SIP/2.0/UDP " + listener.LocalAddr().String() + ";branch=z9hG4bKstatus", + "From: ;tag=gw", + "To: ", + "P-Asserted-Identity: ", + "Call-ID: network-status-1", + "CSeq: 2 MESSAGE", + "Content-Type: application/vnd.3gpp.sms", + fmt.Sprintf("Content-Length: %d", len(statusRPDU)), "", "", + }, "\r\n")) + statusRequest = append(statusRequest, statusRPDU...) + if _, err = listener.WriteToUDP(statusRequest, remote); err != nil { + return err + } + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + statusResponse, err := parseSIPResponse(packet[:count]) + if err != nil || statusResponse.StatusCode != 200 { + return fmt.Errorf("status SIP response = (%#v, %v)", statusResponse, err) + } + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + statusACK, err := parseSIPPacket(packet[:count]) + if err != nil || statusACK.Request == nil || statusACK.Request.value("In-Reply-To") != "network-status-1" || + len(statusACK.Request.Body) != 2 || statusACK.Request.Body[0] != 0x02 || statusACK.Request.Body[1] != 0x2b { + return fmt.Errorf("unexpected status RP-ACK %#v (%v)", statusACK.Request, err) + } + if _, err = listener.WriteToUDP(testResponse(200, "OK", statusACK.Request.value("Call-ID"), statusACK.Request.value("CSeq"), nil), remote); err != nil { + return err + } + + count, remote, err = listener.ReadFromUDP(packet) + if err != nil { + return err + } + _, headers, err = parseTestRequest(packet[:count]) + if err != nil { + return err + } + if headers["expires"] != "0" { + return errors.New("expected deregistration") + } + _, err = listener.WriteToUDP(testResponse(200, "OK", registerCallID, headers["cseq"], nil), remote) + return err +} diff --git a/internal/vowifi/integration/at.go b/internal/vowifi/integration/at.go new file mode 100644 index 0000000..bdc7174 --- /dev/null +++ b/internal/vowifi/integration/at.go @@ -0,0 +1,95 @@ +package integration + +import ( + "context" + "errors" + "strings" + + "vocat/internal/device" + "vocat/internal/modem" + "vocat/internal/store" +) + +type ATDeviceController interface { + Get(string) (device.Device, error) + List() []device.Device + ExecuteAT(context.Context, string, string) (modem.Response, error) + ExecuteSensitiveAT(context.Context, string, string) (modem.Response, error) +} + +// ATMapper lets runtime IDs remain stable configuration IDs even when Linux +// re-enumerates a physical modem under a discovery-derived ID. +type ATMapper struct { + Store *store.Store + Devices ATDeviceController +} + +func (mapper ATMapper) Get(configuredID string) (device.Device, error) { + physicalID, err := mapper.resolve(context.Background(), configuredID) + if err != nil { + return device.Device{}, err + } + return mapper.Devices.Get(physicalID) +} + +func (mapper ATMapper) ExecuteAT( + ctx context.Context, + configuredID string, + command string, +) (modem.Response, error) { + physicalID, err := mapper.resolve(ctx, configuredID) + if err != nil { + return modem.Response{}, err + } + return mapper.Devices.ExecuteAT(ctx, physicalID, command) +} + +func (mapper ATMapper) ExecuteSensitiveAT( + ctx context.Context, + configuredID string, + command string, +) (modem.Response, error) { + physicalID, err := mapper.resolve(ctx, configuredID) + if err != nil { + return modem.Response{}, err + } + return mapper.Devices.ExecuteSensitiveAT(ctx, physicalID, command) +} + +func (mapper ATMapper) resolve( + ctx context.Context, + configuredID string, +) (string, error) { + if mapper.Store == nil || mapper.Devices == nil { + return "", errors.New("vowifi AT mapper is not configured") + } + if entry, err := mapper.Devices.Get(configuredID); err == nil && entry.Discovered { + return entry.ID, nil + } + config, err := mapper.Store.Device(ctx, configuredID) + if err != nil { + return "", err + } + for _, entry := range mapper.Devices.List() { + if !entry.Discovered { + continue + } + candidate := entry.Candidate + switch { + case config.ATPort != "" && + (config.ATPort == candidate.ATPort.Path || + config.ATPort == candidate.ATPort.OpenPath()): + return entry.ID, nil + case config.USBPath != "" && config.USBPath == candidate.USBPath: + return entry.ID, nil + case config.ControlDevice != "" && + (config.ControlDevice == candidate.QMIControl || + config.ControlDevice == candidate.ATPort.OpenPath()): + return entry.ID, nil + case config.ModemIMEI != "" && entry.Snapshot != nil && + config.ModemIMEI == strings.TrimSpace(entry.Snapshot.IMEI): + return entry.ID, nil + } + } + return "", device.ErrNotFound +} diff --git a/internal/vowifi/integration/at_test.go b/internal/vowifi/integration/at_test.go new file mode 100644 index 0000000..438d0ba --- /dev/null +++ b/internal/vowifi/integration/at_test.go @@ -0,0 +1,89 @@ +package integration + +import ( + "context" + "testing" + + "vocat/internal/device" + "vocat/internal/modem" + "vocat/internal/store" +) + +type fakeATDevices struct { + entries []device.Device + executedID string + sensitiveID string +} + +func (devices *fakeATDevices) Get(id string) (device.Device, error) { + for _, entry := range devices.entries { + if entry.ID == id { + return entry, nil + } + } + return device.Device{}, device.ErrNotFound +} + +func (devices *fakeATDevices) List() []device.Device { + return append([]device.Device(nil), devices.entries...) +} + +func (devices *fakeATDevices) ExecuteAT( + _ context.Context, + id string, + _ string, +) (modem.Response, error) { + devices.executedID = id + return modem.Response{Final: "OK"}, nil +} + +func (devices *fakeATDevices) ExecuteSensitiveAT( + _ context.Context, + id string, + _ string, +) (modem.Response, error) { + devices.sensitiveID = id + return modem.Response{Final: "OK"}, nil +} + +func TestATMapperResolvesConfiguredIDByStableATPath(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "living-room", + Name: "EC20", + ATPort: "/dev/serial/by-id/usb-ec20-if02", + }); err != nil { + t.Fatal(err) + } + devices := &fakeATDevices{entries: []device.Device{{ + ID: "usb-1-2", + Discovered: true, + Candidate: modem.Candidate{ + ATPort: modem.Port{ + Path: "/dev/ttyUSB2", + StablePath: "/dev/serial/by-id/usb-ec20-if02", + }, + }, + }}} + mapper := ATMapper{Store: database, Devices: devices} + if _, err := mapper.ExecuteAT( + context.Background(), + "living-room", + "AT", + ); err != nil { + t.Fatal(err) + } + if devices.executedID != "usb-1-2" { + t.Fatalf("ExecuteAT physical ID = %q", devices.executedID) + } + if _, err := mapper.ExecuteSensitiveAT( + context.Background(), + "living-room", + "AT+CSIM=1", + ); err != nil { + t.Fatal(err) + } + if devices.sensitiveID != "usb-1-2" { + t.Fatalf("ExecuteSensitiveAT physical ID = %q", devices.sensitiveID) + } +} diff --git a/internal/vowifi/integration/store.go b/internal/vowifi/integration/store.go new file mode 100644 index 0000000..3626799 --- /dev/null +++ b/internal/vowifi/integration/store.go @@ -0,0 +1,225 @@ +// Package integration connects the protocol runtime to vocat's persistent +// configuration and modem inventory. It contains no IKE, IMS, or SIM protocol +// implementation. +package integration + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "vocat/internal/device" + "vocat/internal/store" + "vocat/internal/vowifi" +) + +type ProxyResolver struct { + Store *store.Store +} + +func (resolver ProxyResolver) Resolve( + ctx context.Context, + request vowifi.ProxyRequest, +) (vowifi.ProxyRoute, error) { + if resolver.Store == nil { + return vowifi.ProxyRoute{}, errors.New("vowifi proxy resolver: store is nil") + } + deviceID := strings.TrimSpace(request.DeviceID) + if deviceID == "" { + return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil + } + binding, err := resolver.Store.DeviceProxyBinding(ctx, deviceID) + if errors.Is(err, store.ErrNotFound) { + return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil + } + if err != nil { + return vowifi.ProxyRoute{}, fmt.Errorf("resolve proxy binding for device %s: %w", deviceID, err) + } + upstream, err := resolver.Store.UpstreamProxy(ctx, binding.UpstreamProxyID) + if err != nil { + return vowifi.ProxyRoute{}, fmt.Errorf( + "load upstream proxy %q for device %s: %w", + binding.UpstreamProxyID, + deviceID, + err, + ) + } + if !upstream.Enabled { + return vowifi.ProxyRoute{}, fmt.Errorf( + "upstream proxy %q for device %s is disabled", + upstream.ID, + deviceID, + ) + } + return vowifi.ProxyRoute{ + Mode: vowifi.ProxyModeSOCKS5, + ID: upstream.ID, + Address: upstream.Addr, + Username: upstream.Username, + Password: upstream.Password, + }, nil +} + +type PhoneStore struct { + Store *store.Store + DeviceID string +} + +func (phones PhoneStore) SaveAssociatedNumber( + ctx context.Context, + record vowifi.PhoneRecord, +) error { + if phones.Store == nil { + return errors.New("vowifi phone store: store is nil") + } + switch record.Source { + case vowifi.PhoneSourceAssociatedMSISDN, vowifi.PhoneSourcePAssociatedURI: + default: + return fmt.Errorf("vowifi phone store: untrusted source %q", record.Source) + } + return phones.Store.UpsertPhoneAssociation(ctx, store.PhoneAssociation{ + ICCID: record.ICCID, + DeviceID: strings.TrimSpace(phones.DeviceID), + Number: record.Number, + Source: record.Source, + UpdatedAt: record.UpdatedAt, + }) +} + +type DeviceReader interface { + Get(string) (device.Device, error) +} + +type StateProjector struct { + Store *store.Store + Devices DeviceReader +} + +func (projector StateProjector) Save( + ctx context.Context, + state vowifi.State, +) error { + if projector.Store == nil { + return errors.New("vowifi state projector: store is nil") + } + runtime := store.VoWiFiRuntime{ + DeviceID: state.DeviceID, + Phase: string(state.Phase), + DataplaneMode: dataplaneMode(state), + SIMReady: state.SIMReady, + AccessReady: state.AccessReady, + TunnelReady: state.TunnelReady, + IMSReady: state.IMSReady, + SMSReady: state.SMSReady, + RegStatus: boolInt(state.IMSReady), + RegStatusText: registrationText(state), + NetworkMode: "Wi-Fi", + LocalPhone: state.PhoneNumber, + PhoneNumberSource: state.PhoneNumberSource, + LastErrorClass: state.LastErrorClass, + LastError: state.LastError, + LastReason: state.LastReason, + UpdatedAt: state.UpdatedAt, + } + if projector.Devices != nil { + if entry, err := projector.Devices.Get(state.DeviceID); err == nil && entry.Snapshot != nil { + runtime.ICCID = strings.TrimSpace(entry.Snapshot.ICCID) + runtime.IMSI = strings.TrimSpace(entry.Snapshot.IMSI) + } + } + if runtime.LocalPhone == "" && runtime.ICCID != "" { + if association, err := projector.Store.PhoneAssociation(ctx, runtime.ICCID); err == nil { + runtime.LocalPhone = association.Number + runtime.PhoneNumberSource = association.Source + } else if !errors.Is(err, store.ErrNotFound) { + return fmt.Errorf("restore associated phone number: %w", err) + } + } + + var err error + runtime.Tunnel, err = marshalObject(map[string]any{ + "established": state.TunnelReady, + "name": state.TunnelName, + "dataplane_mode": state.DataplaneMode, + "epdg": state.EPDG, + "proxy_mode": state.ProxyMode, + "proxy_id": state.ProxyID, + "security_audit": state.Security, + }) + if err != nil { + return err + } + runtime.IMSCore, err = marshalObject(map[string]any{ + "registered": state.IMSReady, + "registration_state": state.IMSRegistration, + "associated_number": runtime.LocalPhone, + "number_source": runtime.PhoneNumberSource, + }) + if err != nil { + return err + } + runtime.SMSIP, err = marshalObject(map[string]any{ + "ready": state.SMSReady, + }) + if err != nil { + return err + } + runtime.Extra, err = marshalObject(map[string]any{ + "enabled": state.Enabled, + "active": state.Active, + "pure_airplane_policy": state.PureAirplanePolicy, + "home_mcc": state.HomeMCC, + "home_mnc": state.HomeMNC, + "warnings": state.Warnings, + "cleanup_errors": state.CleanupErrors, + "attempt": state.Attempt, + "sequence": state.Sequence, + "started_at": state.StartedAt, + }) + if err != nil { + return err + } + return projector.Store.UpsertVoWiFiRuntime(ctx, runtime) +} + +func dataplaneMode(state vowifi.State) string { + if value := strings.TrimSpace(state.DataplaneMode); value == "userspace" || value == "xfrm" { + return value + } + if state.TunnelReady || state.Phase == vowifi.PhaseTunnelReady || + state.Phase == vowifi.PhaseIMSReady || state.Phase == vowifi.PhaseSMSReady { + return "ipsec" + } + return "" +} + +func registrationText(state vowifi.State) string { + if value := strings.TrimSpace(state.IMSRegistration); value != "" { + return value + } + if state.IMSReady { + return "registered" + } + if state.Phase == vowifi.PhaseFailed && + strings.HasPrefix(state.LastErrorClass, "ims") { + return "registration failed" + } + return "not registered" +} + +func boolInt(value bool) int { + if value { + return 1 + } + return 0 +} + +func marshalObject(value map[string]any) (json.RawMessage, error) { + encoded, err := json.Marshal(value) + if err != nil { + return nil, fmt.Errorf("marshal VoWiFi runtime projection: %w", err) + } + return encoded, nil +} diff --git a/internal/vowifi/integration/store_test.go b/internal/vowifi/integration/store_test.go new file mode 100644 index 0000000..26a18de --- /dev/null +++ b/internal/vowifi/integration/store_test.go @@ -0,0 +1,201 @@ +package integration + +import ( + "context" + "encoding/json" + "testing" + "time" + + "vocat/internal/device" + "vocat/internal/store" + "vocat/internal/vowifi" +) + +func testStore(t *testing.T) *store.Store { + t.Helper() + database, err := store.Open(context.Background(), ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func TestProxyResolverUsesDeviceBinding(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ID: "ec20", Name: "EC20"}); err != nil { + t.Fatal(err) + } + if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ + ID: "clash", + Name: "Clash", + Addr: "192.168.2.143:7897", + Enabled: true, + Password: "must-not-be-lost", + Username: "proxy-user", + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertDeviceProxyBinding(context.Background(), store.DeviceProxyBinding{ + DeviceID: "ec20", + UpstreamProxyID: "clash", + }); err != nil { + t.Fatal(err) + } + route, err := (ProxyResolver{Store: database}).Resolve( + context.Background(), + vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234", HomeMNC: "15"}, + ) + if err != nil { + t.Fatal(err) + } + if route.Mode != vowifi.ProxyModeSOCKS5 || + route.Address != "192.168.2.143:7897" || + route.Username != "proxy-user" || + route.Password != "must-not-be-lost" { + t.Fatalf("route = %#v", route) + } +} + +func TestProxyResolverDoesNotUseCountryRuleWithoutDeviceBinding(t *testing.T) { + database := testStore(t) + if err := database.UpsertUpstreamProxy(context.Background(), store.UpstreamProxy{ + ID: "legacy", Name: "Legacy", Addr: "127.0.0.1:1080", Enabled: true, + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertCountryRule(context.Background(), store.CountryRule{ + CountryCode: "GB", CountryName: "United Kingdom", UpstreamProxyID: "legacy", Enabled: true, + }); err != nil { + t.Fatal(err) + } + route, err := (ProxyResolver{Store: database}).Resolve( + context.Background(), + vowifi.ProxyRequest{DeviceID: "ec20", HomeMCC: "234"}, + ) + if err != nil { + t.Fatal(err) + } + if route.Mode != vowifi.ProxyModeDirect { + t.Fatalf("route = %#v, want direct", route) + } +} + +func TestPhoneStoreRejectsUntrustedSource(t *testing.T) { + database := testStore(t) + phones := PhoneStore{Store: database, DeviceID: "ec20"} + record := vowifi.PhoneRecord{ + ICCID: "89441000400311061404", + Number: "+447700900123", + Source: "imsi_guess", + UpdatedAt: time.Now(), + } + if err := phones.SaveAssociatedNumber(context.Background(), record); err == nil { + t.Fatal("untrusted source was accepted") + } + record.Source = vowifi.PhoneSourcePAssociatedURI + if err := phones.SaveAssociatedNumber(context.Background(), record); err != nil { + t.Fatal(err) + } + got, err := database.PhoneAssociation(context.Background(), record.ICCID) + if err != nil { + t.Fatal(err) + } + if got.Number != record.Number || got.Source != record.Source { + t.Fatalf("association = %#v", got) + } +} + +func TestStateProjectorRestoresVerifiedNumber(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "ec20", + Name: "EC20", + }); err != nil { + t.Fatal(err) + } + if err := database.UpsertPhoneAssociation(context.Background(), store.PhoneAssociation{ + ICCID: "89441000400311061404", + DeviceID: "ec20", + Number: "+447700900123", + Source: vowifi.PhoneSourcePAssociatedURI, + }); err != nil { + t.Fatal(err) + } + projector := StateProjector{ + Store: database, + Devices: staticDeviceReader{ + iccid: "89441000400311061404", + imsi: "234159598901845", + }, + } + if err := projector.Save(context.Background(), vowifi.State{ + DeviceID: "ec20", + Phase: vowifi.PhaseIdle, + UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + runtime, err := database.VoWiFiRuntime(context.Background(), "ec20") + if err != nil { + t.Fatal(err) + } + if runtime.LocalPhone != "+447700900123" || + runtime.PhoneNumberSource != vowifi.PhoneSourcePAssociatedURI { + t.Fatalf("runtime phone = %q (%q)", runtime.LocalPhone, runtime.PhoneNumberSource) + } + var tunnel map[string]any + if err := json.Unmarshal(runtime.Tunnel, &tunnel); err != nil { + t.Fatal(err) + } +} + +func TestStateProjectorPreservesConcreteDataplaneMode(t *testing.T) { + database := testStore(t) + if err := database.UpsertDevice(context.Background(), store.Device{ + ID: "ec25", + Name: "EC25", + }); err != nil { + t.Fatal(err) + } + projector := StateProjector{Store: database} + if err := projector.Save(context.Background(), vowifi.State{ + DeviceID: "ec25", + Phase: vowifi.PhaseIMSReady, + TunnelReady: true, + IMSReady: true, + TunnelName: "vocat-swu-ec25", + DataplaneMode: "userspace", + UpdatedAt: time.Now().UTC(), + }); err != nil { + t.Fatal(err) + } + runtime, err := database.VoWiFiRuntime(context.Background(), "ec25") + if err != nil { + t.Fatal(err) + } + if runtime.DataplaneMode != "userspace" { + t.Fatalf("dataplane mode = %q, want userspace", runtime.DataplaneMode) + } + var tunnel map[string]any + if err := json.Unmarshal(runtime.Tunnel, &tunnel); err != nil { + t.Fatal(err) + } + if tunnel["dataplane_mode"] != "userspace" { + t.Fatalf("tunnel dataplane mode = %#v", tunnel["dataplane_mode"]) + } +} + +type staticDeviceReader struct { + iccid string + imsi string +} + +func (reader staticDeviceReader) Get(string) (device.Device, error) { + return device.Device{ + Snapshot: &device.Snapshot{ + ICCID: reader.iccid, + IMSI: reader.imsi, + }, + }, nil +} diff --git a/internal/vowifi/orchestrator.go b/internal/vowifi/orchestrator.go new file mode 100644 index 0000000..5326146 --- /dev/null +++ b/internal/vowifi/orchestrator.go @@ -0,0 +1,811 @@ +package vowifi + +import ( + "context" + "errors" + "fmt" + "net" + "strings" + "sync" + "time" +) + +const defaultCleanupTimeout = 10 * time.Second + +type runtimeResources struct { + cancel context.CancelFunc + radio RadioSnapshot + radioChanged bool + tunnel TunnelSession + ims IMSSession +} + +// Orchestrator serializes lifecycle mutations while allowing concurrent state +// readers and subscribers. Disable cancels an in-flight Enable before waiting +// for the mutation lock, so a blocked provider cannot deadlock shutdown. +type Orchestrator struct { + deps Dependencies + options Options + + operation chan struct{} + + mu sync.Mutex + state State + resources *runtimeResources + subscribers map[uint64]chan State + nextSubscriber uint64 +} + +func New(deps Dependencies, options Options) (*Orchestrator, error) { + if err := deps.validate(); err != nil { + return nil, err + } + if err := options.validate(); err != nil { + return nil, err + } + if options.CleanupTimeout == 0 { + options.CleanupTimeout = defaultCleanupTimeout + } + options.DeviceID = strings.TrimSpace(options.DeviceID) + + now := time.Now().UTC() + orchestrator := &Orchestrator{ + deps: deps, + options: options, + operation: make(chan struct{}, 1), + state: State{ + DeviceID: strings.TrimSpace(options.DeviceID), + Phase: PhaseIdle, + Sequence: 1, + UpdatedAt: now, + Security: SecurityAudit{ + ResponderAUTH: ResponderAUTHUnknown, + }, + }, + subscribers: make(map[uint64]chan State), + } + orchestrator.operation <- struct{}{} + return orchestrator, nil +} + +// State returns a detached snapshot safe for mutation by the caller. +func (orchestrator *Orchestrator) State() State { + orchestrator.mu.Lock() + defer orchestrator.mu.Unlock() + return orchestrator.state.clone() +} + +// Subscribe returns the current state immediately and then the newest state on +// every mutation. Slow subscribers lose intermediate snapshots rather than +// blocking the modem lifecycle. +func (orchestrator *Orchestrator) Subscribe(buffer int) (<-chan State, func()) { + if buffer < 1 { + buffer = 1 + } + channel := make(chan State, buffer) + + orchestrator.mu.Lock() + id := orchestrator.nextSubscriber + orchestrator.nextSubscriber++ + orchestrator.subscribers[id] = channel + channel <- orchestrator.state.clone() + orchestrator.mu.Unlock() + + var once sync.Once + cancel := func() { + once.Do(func() { + orchestrator.mu.Lock() + if existing, ok := orchestrator.subscribers[id]; ok { + delete(orchestrator.subscribers, id) + close(existing) + } + orchestrator.mu.Unlock() + }) + } + return channel, cancel +} + +// Enable executes one evidence-backed transaction. The order intentionally +// follows the working Linux/QMI path: live identity and home PLMN, AKA +// availability, ePDG derivation, runtime-owned RF off, cellular-data stop, +// country proxy resolution, SWu tunnel, IMS registration, and SMS readiness. +func (orchestrator *Orchestrator) Enable(ctx context.Context) (State, error) { + if ctx == nil { + ctx = context.Background() + } + if err := orchestrator.lockOperation(ctx); err != nil { + return orchestrator.State(), err + } + defer orchestrator.unlockOperation() + + current := orchestrator.State() + switch current.Phase { + case PhaseSIMReady, PhaseAccessReady, PhaseTunnelReady, PhaseIMSReady, PhaseSMSReady, PhaseStopping: + return current, ErrAlreadyEnabled + } + + now := time.Now().UTC() + orchestrator.mutate(func(state *State) { + attempt := state.Attempt + 1 + sequence := state.Sequence + *state = State{ + DeviceID: orchestrator.options.DeviceID, + Phase: PhaseIdle, + Enabled: true, + Attempt: attempt, + Sequence: sequence, + StartedAt: &now, + UpdatedAt: now, + LastReason: "enable_requested", + Security: SecurityAudit{ + ResponderAUTH: ResponderAUTHUnknown, + }, + } + }) + + runtimeContext, runtimeCancel := context.WithCancel(context.Background()) + resources := &runtimeResources{cancel: runtimeCancel} + orchestrator.mu.Lock() + orchestrator.resources = resources + orchestrator.mu.Unlock() + + setupContext, stopSetup := mergedContext(ctx, runtimeContext) + defer stopSetup() + + fail := func(stage Phase, cause error) (State, error) { + runtimeCancel() + cleanupErrors := orchestrator.cleanup(resources) + orchestrator.mu.Lock() + orchestrator.resources = nil + orchestrator.mu.Unlock() + + orchestrator.mutate(func(state *State) { + state.Phase = PhaseFailed + state.Active = false + state.TunnelReady = false + state.IMSReady = false + state.SMSReady = false + state.LastErrorClass = classifyError(stage, cause) + state.LastError = cause.Error() + state.LastReason = "enable_failed" + state.CleanupErrors = append([]string(nil), cleanupErrors...) + }) + + stageError := error(&StageError{Stage: stage, Err: cause}) + if len(cleanupErrors) > 0 { + stageError = errors.Join( + stageError, + fmt.Errorf("vowifi cleanup: %s", strings.Join(cleanupErrors, "; ")), + ) + } + return orchestrator.State(), stageError + } + + identity, err := orchestrator.deps.SIM.ReadIdentity(setupContext, orchestrator.options.DeviceID) + if err != nil { + return fail(PhaseSIMReady, err) + } + if err := identity.validate(); err != nil { + return fail(PhaseSIMReady, err) + } + akaEvidence, err := orchestrator.deps.AKA.CheckReady(setupContext, identity) + if err != nil { + return fail(PhaseSIMReady, err) + } + if !akaEvidence.Ready { + return fail(PhaseSIMReady, errors.New("AKA application is not ready")) + } + if reader, ok := orchestrator.deps.SIM.(SMSCenterReader); ok { + if smsc, smscErr := reader.ReadSMSCenter(setupContext, orchestrator.options.DeviceID); smscErr == nil { + identity.SMSC = strings.TrimSpace(smsc) + } else { + orchestrator.addWarning("SIM SMS service-centre address is unavailable; IMS receive remains available: " + smscErr.Error()) + } + } + orchestrator.mutate(func(state *State) { + state.Phase = PhaseSIMReady + state.SIMReady = true + state.HomeMCC = strings.TrimSpace(identity.HomeMCC) + state.HomeMNC = strings.TrimSpace(identity.HomeMNC) + state.LastReason = "sim_and_aka_ready" + }) + + epdg, err := DeriveEPDG(identity) + if err != nil { + return fail(PhaseAccessReady, err) + } + resources.radio, err = orchestrator.deps.Radio.Snapshot(setupContext, orchestrator.options.DeviceID) + if err != nil { + return fail(PhaseAccessReady, err) + } + orchestrator.mutate(func(state *State) { + state.PureAirplanePolicy = resources.radio.PureAirplanePolicy + }) + // Mark the radio transaction before the first mutating call: a provider + // may return an error after partially changing the modem. + resources.radioChanged = true + // Enter RF-off before reconciling PDP contexts. Some QMI-capable EC20 + // firmware automatically owns CID 1 while CFUN=1 and rejects a direct + // CGACT=0 command even though the Linux data interface is down. CFUN=4 + // tears down packet service at the baseband; StopCellularData then acts as + // a fail-closed verification and removes any context that unexpectedly + // survived RF-off. + if err := orchestrator.deps.Radio.EnterVoWiFiRFOff(setupContext, orchestrator.options.DeviceID); err != nil { + return fail(PhaseAccessReady, err) + } + if err := orchestrator.deps.Radio.StopCellularData(setupContext, orchestrator.options.DeviceID); err != nil { + return fail(PhaseAccessReady, err) + } + + proxy, err := orchestrator.deps.Proxy.Resolve(setupContext, ProxyRequest{ + DeviceID: orchestrator.options.DeviceID, + HomeMCC: strings.TrimSpace(identity.HomeMCC), + HomeMNC: strings.TrimSpace(identity.HomeMNC), + CountryCode: strings.ToUpper(strings.TrimSpace(identity.HomeCountryCode)), + }) + if err != nil { + return fail(PhaseAccessReady, err) + } + proxy, err = normalizeProxyRoute(proxy) + if err != nil { + return fail(PhaseAccessReady, err) + } + orchestrator.mutate(func(state *State) { + state.Phase = PhaseAccessReady + state.AccessReady = true + state.EPDG = epdg + state.ProxyMode = proxy.Mode + state.ProxyID = proxy.ID + state.LastReason = "epdg_access_ready" + }) + + tunnel, err := orchestrator.deps.Tunnel.Start(setupContext, TunnelRequest{ + DeviceID: orchestrator.options.DeviceID, + Identity: identity, + EPDG: epdg, + Proxy: proxy, + AKA: orchestrator.deps.AKA, + Security: TunnelSecurityPolicy{ + AllowMissingResponderAUTH: orchestrator.options.AllowMissingResponderAUTH, + }, + }) + if err != nil { + return fail(PhaseTunnelReady, err) + } + if tunnel == nil { + return fail(PhaseTunnelReady, errors.New("tunnel provider returned a nil session")) + } + resources.tunnel = tunnel + tunnelEvidence := tunnel.Evidence() + if !tunnelEvidence.Established { + orchestrator.mutate(func(state *State) { + state.Security = securityAuditFromEvidence(tunnelEvidence) + }) + return fail(PhaseTunnelReady, ErrTunnelNotEstablished) + } + securityAudit, err := orchestrator.validateTunnelEvidence(tunnelEvidence) + orchestrator.mutate(func(state *State) { + state.Security = securityAudit + }) + if err != nil { + return fail(PhaseTunnelReady, err) + } + orchestrator.mutate(func(state *State) { + state.Phase = PhaseTunnelReady + state.Active = true + state.TunnelReady = true + state.TunnelName = strings.TrimSpace(tunnelEvidence.Name) + state.DataplaneMode = strings.TrimSpace(tunnelEvidence.DataplaneMode) + state.LastReason = "ipsec_tunnel_ready" + }) + orchestrator.watchRuntimeTunnel(runtimeContext, resources, tunnel) + + ims, err := orchestrator.deps.IMS.Start(setupContext, IMSRequest{ + DeviceID: orchestrator.options.DeviceID, + Identity: identity, + Tunnel: tunnel, + }) + if err != nil { + return fail(PhaseIMSReady, err) + } + if ims == nil { + return fail(PhaseIMSReady, errors.New("IMS provider returned a nil session")) + } + resources.ims = ims + orchestrator.watchRuntimeIMS(runtimeContext, resources, ims) + imsEvidence := ims.Evidence() + if !imsEvidence.Registered { + return fail(PhaseIMSReady, ErrIMSNotRegistered) + } + orchestrator.mutate(func(state *State) { + state.Phase = PhaseIMSReady + state.IMSReady = true + state.IMSRegistration = strings.TrimSpace(imsEvidence.RegistrationState) + state.LastReason = "ims_registered" + }) + + if number, source, ok := ExtractAssociatedMSISDN(imsEvidence); ok { + record := PhoneRecord{ + ICCID: strings.TrimSpace(identity.ICCID), + Number: number, + Source: source, + UpdatedAt: time.Now().UTC(), + } + if err := orchestrator.deps.Phones.SaveAssociatedNumber(setupContext, record); err != nil { + orchestrator.addWarning("IMS associated number is valid but could not be persisted: " + err.Error()) + } else { + orchestrator.mutate(func(state *State) { + state.PhoneNumber = number + state.PhoneNumberSource = source + }) + } + } else { + orchestrator.addWarning("IMS did not publish an associated MSISDN; the number was not inferred from IMSI") + } + + smsEvidence, err := ims.EnableSMS(setupContext) + if err != nil { + if orchestrator.options.AllowIMSWithoutSMS { + orchestrator.addWarning("IMS is registered but SMS capability was not confirmed: " + err.Error()) + orchestrator.mutate(func(state *State) { + state.LastReason = "ims_registered_sms_unavailable" + state.LastError = "" + state.LastErrorClass = "" + state.CleanupErrors = nil + }) + return orchestrator.State(), nil + } + return fail(PhaseSMSReady, err) + } + if !smsEvidence.Ready { + if orchestrator.options.AllowIMSWithoutSMS { + orchestrator.addWarning("IMS is registered but SMS capability was not confirmed") + orchestrator.mutate(func(state *State) { + state.LastReason = "ims_registered_sms_unavailable" + state.LastError = "" + state.LastErrorClass = "" + state.CleanupErrors = nil + }) + return orchestrator.State(), nil + } + return fail(PhaseSMSReady, ErrSMSNotReady) + } + orchestrator.mutate(func(state *State) { + state.Phase = PhaseSMSReady + state.SMSReady = true + state.LastReason = "sms_ready" + state.LastError = "" + state.LastErrorClass = "" + state.CleanupErrors = nil + }) + return orchestrator.State(), nil +} + +// Disable is idempotent. It interrupts setup when necessary and closes IMS, +// tunnel, then restores the captured radio state. +func (orchestrator *Orchestrator) Disable(ctx context.Context) (State, error) { + if ctx == nil { + ctx = context.Background() + } + orchestrator.cancelCurrentRuntime() + if err := orchestrator.lockOperation(ctx); err != nil { + return orchestrator.State(), err + } + defer orchestrator.unlockOperation() + + orchestrator.mu.Lock() + resources := orchestrator.resources + orchestrator.mu.Unlock() + current := orchestrator.State() + if resources == nil && current.Phase == PhaseIdle { + return current, nil + } + + orchestrator.mutate(func(state *State) { + state.Phase = PhaseStopping + state.Enabled = false + state.LastReason = "disable_requested" + }) + if resources != nil && resources.cancel != nil { + resources.cancel() + } + cleanupErrors := orchestrator.cleanup(resources) + orchestrator.mu.Lock() + orchestrator.resources = nil + orchestrator.mu.Unlock() + + if len(cleanupErrors) > 0 { + cause := fmt.Errorf("%w: %s", ErrCleanupIncomplete, strings.Join(cleanupErrors, "; ")) + orchestrator.mutate(func(state *State) { + // cleanup() has already released every local resource and restored + // the radio. A rejected best-effort SIP deregistration is useful + // diagnostic evidence, but it must not leave a disabled runtime in + // Failed/Stopping or prevent a later cellular/VoWiFi transition. + state.Phase = PhaseIdle + state.Enabled = false + state.Active = false + state.SIMReady = false + state.AccessReady = false + state.TunnelReady = false + state.IMSReady = false + state.SMSReady = false + state.TunnelName = "" + state.DataplaneMode = "" + state.IMSRegistration = "" + state.LastErrorClass = "cleanup_warning" + state.LastError = cause.Error() + state.LastReason = "disabled_with_cleanup_errors" + state.CleanupErrors = append([]string(nil), cleanupErrors...) + state.StartedAt = nil + }) + return orchestrator.State(), cause + } + + orchestrator.mutate(func(state *State) { + state.Phase = PhaseIdle + state.Enabled = false + state.Active = false + state.SIMReady = false + state.AccessReady = false + state.TunnelReady = false + state.IMSReady = false + state.SMSReady = false + state.TunnelName = "" + state.DataplaneMode = "" + state.IMSRegistration = "" + state.LastErrorClass = "" + state.LastError = "" + state.LastReason = "disabled" + state.CleanupErrors = nil + state.StartedAt = nil + }) + return orchestrator.State(), nil +} + +func (orchestrator *Orchestrator) Retry(ctx context.Context) (State, error) { + if orchestrator.State().Phase != PhaseFailed { + return orchestrator.State(), ErrRetryRequiresFailure + } + return orchestrator.Enable(ctx) +} + +func (orchestrator *Orchestrator) Reconnect(ctx context.Context) (State, error) { + current := orchestrator.State() + if !current.Enabled && current.Phase == PhaseIdle { + return current, ErrNotRunning + } + // Teardown during a reconnect is best-effort. Disable already releases the + // local IMS, tunnel, and radio resources, so a non-fatal cleanup error + // (e.g. the network rejecting SIP deregistration) must not block the + // rebuild — otherwise the device wedges in PhaseFailed. Only propagate + // errors that prevented the teardown itself (e.g. the operation lock). + if _, err := orchestrator.Disable(ctx); err != nil && !errors.Is(err, ErrCleanupIncomplete) { + return orchestrator.State(), err + } + return orchestrator.Enable(ctx) +} + +// SendSMS submits through the currently registered IMS session. The lifecycle +// operation lock prevents teardown from closing the session mid-transaction. +func (orchestrator *Orchestrator) SendSMS( + ctx context.Context, + request SMSSubmitRequest, +) (SMSSubmitResult, error) { + if ctx == nil { + ctx = context.Background() + } + if err := orchestrator.lockOperation(ctx); err != nil { + return SMSSubmitResult{}, err + } + defer orchestrator.unlockOperation() + orchestrator.mu.Lock() + resources := orchestrator.resources + ready := orchestrator.state.IMSReady && orchestrator.state.SMSReady + orchestrator.mu.Unlock() + if resources == nil || resources.ims == nil || !ready { + return SMSSubmitResult{}, ErrSMSNotReady + } + sender, ok := resources.ims.(SMSSender) + if !ok { + return SMSSubmitResult{}, ErrSMSNotReady + } + return sender.SendSMS(ctx, request) +} + +func (orchestrator *Orchestrator) Close(ctx context.Context) error { + _, err := orchestrator.Disable(ctx) + return err +} + +// DeriveEPDG uses an explicitly provided carrier endpoint or the 3GPP standard +// home-PLMN form. It never derives a phone number or MNC length from IMSI. +func DeriveEPDG(identity SIMIdentity) (string, error) { + if configured := strings.TrimSpace(identity.EPDG); configured != "" { + if strings.ContainsAny(configured, " \t\r\n/:") || len(configured) > 253 { + return "", errors.New("vowifi: configured ePDG must be a hostname") + } + return strings.ToLower(configured), nil + } + if err := identity.validate(); err != nil { + return "", err + } + mnc := strings.TrimSpace(identity.HomeMNC) + for len(mnc) < 3 { + mnc = "0" + mnc + } + return fmt.Sprintf( + "epdg.epc.mnc%s.mcc%s.pub.3gppnetwork.org", + mnc, + strings.TrimSpace(identity.HomeMCC), + ), nil +} + +func normalizeProxyRoute(route ProxyRoute) (ProxyRoute, error) { + if route.Mode == "" { + route.Mode = ProxyModeDirect + } + switch route.Mode { + case ProxyModeDirect: + route.Address = "" + route.Username = "" + route.Password = "" + case ProxyModeSOCKS5: + if strings.TrimSpace(route.Address) == "" { + return ProxyRoute{}, errors.New("vowifi: SOCKS5 proxy address is empty") + } + default: + return ProxyRoute{}, fmt.Errorf("vowifi: unsupported proxy mode %q", route.Mode) + } + route.ID = strings.TrimSpace(route.ID) + route.Address = strings.TrimSpace(route.Address) + return route, nil +} + +func (orchestrator *Orchestrator) validateTunnelEvidence(evidence TunnelEvidence) (SecurityAudit, error) { + audit := securityAuditFromEvidence(evidence) + switch evidence.ResponderAUTH { + case ResponderAUTHVerified: + return audit, nil + case ResponderAUTHMissing: + if !orchestrator.options.AllowMissingResponderAUTH { + return audit, ErrResponderAUTHRequired + } + audit.CompatibilityOverride = true + audit.HighRisk = true + audit.Level = AuditLevelHigh + audit.Code = AuditCodeMissingResponderAUTH + audit.Message = "IKE responder AUTH was missing and accepted by explicit compatibility policy" + return audit, nil + case ResponderAUTHInvalid: + return audit, fmt.Errorf("%w: responder AUTH is invalid", ErrResponderAUTHRequired) + default: + return audit, fmt.Errorf("%w: responder AUTH evidence is unknown", ErrResponderAUTHRequired) + } +} + +func securityAuditFromEvidence(evidence TunnelEvidence) SecurityAudit { + return SecurityAudit{ + ResponderAUTH: evidence.ResponderAUTH, + IKEEncryption: strings.TrimSpace(evidence.IKEEncryption), + IKEIntegrity: strings.TrimSpace(evidence.IKEIntegrity), + IKEDHGroup: strings.TrimSpace(evidence.IKEDHGroup), + ESPEncryption: strings.TrimSpace(evidence.ESPEncryption), + ESPIntegrity: strings.TrimSpace(evidence.ESPIntegrity), + } +} + +func (orchestrator *Orchestrator) cleanup(resources *runtimeResources) []string { + if resources == nil { + return nil + } + var cleanupErrors []string + if resources.ims != nil { + if err := orchestrator.cleanupCall(resources.ims.Close); err != nil { + cleanupErrors = append(cleanupErrors, "close IMS: "+err.Error()) + } + resources.ims = nil + } + if resources.tunnel != nil { + if err := orchestrator.cleanupCall(resources.tunnel.Close); err != nil { + cleanupErrors = append(cleanupErrors, "close tunnel: "+err.Error()) + } + resources.tunnel = nil + } + if resources.radioChanged { + if err := orchestrator.cleanupCall(func(ctx context.Context) error { + return orchestrator.deps.Radio.Restore(ctx, orchestrator.options.DeviceID, resources.radio) + }); err != nil { + cleanupErrors = append(cleanupErrors, "restore radio: "+err.Error()) + } + resources.radioChanged = false + } + return cleanupErrors +} + +func (orchestrator *Orchestrator) cleanupCall(call func(context.Context) error) error { + ctx, cancel := context.WithTimeout(context.Background(), orchestrator.options.CleanupTimeout) + defer cancel() + return call(ctx) +} + +func (orchestrator *Orchestrator) cancelCurrentRuntime() { + orchestrator.mu.Lock() + resources := orchestrator.resources + orchestrator.mu.Unlock() + if resources != nil && resources.cancel != nil { + resources.cancel() + } +} + +func (orchestrator *Orchestrator) watchRuntimeTunnel( + runtimeContext context.Context, + resources *runtimeResources, + tunnel TunnelSession, +) { + notifier, ok := tunnel.(RuntimeFailureNotifier) + if !ok { + return + } + orchestrator.watchRuntimeFailure( + runtimeContext, + resources, + notifier, + "tunnel_runtime", + "runtime_tunnel_failed", + ) +} + +func (orchestrator *Orchestrator) watchRuntimeIMS( + runtimeContext context.Context, + resources *runtimeResources, + ims IMSSession, +) { + notifier, ok := ims.(RuntimeFailureNotifier) + if !ok { + return + } + orchestrator.watchRuntimeFailure( + runtimeContext, + resources, + notifier, + "ims_runtime", + "runtime_ims_failed", + ) +} + +func (orchestrator *Orchestrator) watchRuntimeFailure( + runtimeContext context.Context, + resources *runtimeResources, + notifier RuntimeFailureNotifier, + errorClass string, + reason string, +) { + failures := notifier.Failures() + if failures == nil { + return + } + go func() { + select { + case <-runtimeContext.Done(): + return + case cause := <-failures: + if cause == nil { + cause = errors.New("VoWiFi runtime session stopped") + } + // Interrupt any still-running IMS setup before waiting for the + // serialized lifecycle lock. + if resources.cancel != nil { + resources.cancel() + } + if err := orchestrator.lockOperation(context.Background()); err != nil { + return + } + defer orchestrator.unlockOperation() + + orchestrator.mu.Lock() + current := orchestrator.resources == resources + orchestrator.mu.Unlock() + if !current { + return + } + cleanupErrors := orchestrator.cleanup(resources) + orchestrator.mu.Lock() + if orchestrator.resources == resources { + orchestrator.resources = nil + } + orchestrator.mu.Unlock() + orchestrator.mutate(func(state *State) { + state.Phase = PhaseFailed + state.Active = false + state.TunnelReady = false + state.IMSReady = false + state.SMSReady = false + state.LastErrorClass = errorClass + state.LastError = cause.Error() + state.LastReason = reason + state.CleanupErrors = append([]string(nil), cleanupErrors...) + }) + } + }() +} + +func (orchestrator *Orchestrator) lockOperation(ctx context.Context) error { + select { + case <-ctx.Done(): + return ctx.Err() + case <-orchestrator.operation: + return nil + } +} + +func (orchestrator *Orchestrator) unlockOperation() { + orchestrator.operation <- struct{}{} +} + +func (orchestrator *Orchestrator) mutate(change func(*State)) { + orchestrator.mu.Lock() + change(&orchestrator.state) + orchestrator.state.Sequence++ + orchestrator.state.UpdatedAt = time.Now().UTC() + snapshot := orchestrator.state.clone() + for _, subscriber := range orchestrator.subscribers { + select { + case subscriber <- snapshot: + default: + select { + case <-subscriber: + default: + } + select { + case subscriber <- snapshot: + default: + } + } + } + orchestrator.mu.Unlock() +} + +func (orchestrator *Orchestrator) addWarning(warning string) { + orchestrator.mutate(func(state *State) { + state.Warnings = append(state.Warnings, warning) + }) +} + +func classifyError(stage Phase, err error) string { + switch { + case errors.Is(err, context.Canceled): + return "canceled" + case errors.Is(err, context.DeadlineExceeded): + return "timeout" + case isTimeoutError(err): + return "network_timeout" + case errors.Is(err, ErrInvalidIdentity): + return "sim_identity" + case errors.Is(err, ErrEAPAuthenticationRejected): + return "eap_authentication_rejected" + case errors.Is(err, ErrResponderAUTHRequired): + return "responder_auth" + case errors.Is(err, ErrTunnelNotEstablished): + return "tunnel" + case errors.Is(err, ErrIMSNotRegistered): + return "ims_registration" + case errors.Is(err, ErrSMSNotReady): + return "sms" + default: + return string(stage) + } +} + +func isTimeoutError(err error) bool { + var networkError net.Error + return errors.As(err, &networkError) && networkError.Timeout() +} + +func mergedContext(caller context.Context, runtime context.Context) (context.Context, func()) { + merged, cancel := context.WithCancel(caller) + stop := context.AfterFunc(runtime, cancel) + return merged, func() { + stop() + cancel() + } +} diff --git a/internal/vowifi/orchestrator_test.go b/internal/vowifi/orchestrator_test.go new file mode 100644 index 0000000..6a41c69 --- /dev/null +++ b/internal/vowifi/orchestrator_test.go @@ -0,0 +1,907 @@ +package vowifi + +import ( + "context" + "errors" + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func TestClassifyErrorEAPAuthenticationRejected(t *testing.T) { + err := fmt.Errorf("tunnel setup: %w", ErrEAPAuthenticationRejected) + if got := classifyError(PhaseTunnelReady, err); got != "eap_authentication_rejected" { + t.Fatalf("classifyError = %q", got) + } +} + +type fakeEnvironment struct { + mu sync.Mutex + + calls []string + failCounts map[string]int + blockAt string + blocked chan struct{} + blockOnce sync.Once + + identity SIMIdentity + akaEvidence AKAEvidence + radioSnapshot RadioSnapshot + proxy ProxyRoute + tunnelEvidence TunnelEvidence + imsEvidence IMSEvidence + smsEvidence SMSEvidence + phoneRecords []PhoneRecord + tunnelRequests []TunnelRequest + tunnelFailures chan error + imsFailures chan error +} + +func newFakeEnvironment() *fakeEnvironment { + return &fakeEnvironment{ + failCounts: make(map[string]int), + blocked: make(chan struct{}), + identity: SIMIdentity{ + ICCID: "8944100000000000000", + IMSI: "234150000000000", + IMEI: "860000000000000", + HomeMCC: "234", + HomeMNC: "15", + HomeCountryCode: "GB", + }, + akaEvidence: AKAEvidence{ + Ready: true, + Application: "usim", + }, + radioSnapshot: RadioSnapshot{ + CellularDataEnabled: true, + OperatingMode: 1, + PureAirplanePolicy: false, + }, + proxy: ProxyRoute{Mode: ProxyModeDirect}, + tunnelEvidence: TunnelEvidence{ + Established: true, + Name: "vowifi0", + ResponderAUTH: ResponderAUTHVerified, + IKEEncryption: "aes-cbc-128", + IKEIntegrity: "hmac-sha2-256", + IKEDHGroup: "modp2048", + ESPEncryption: "aes-cbc-128", + ESPIntegrity: "hmac-sha1-96", + }, + imsEvidence: IMSEvidence{ + Registered: true, + RegistrationState: "registered", + AssociatedMSISDN: "+447700900123@ims.mnc015.mcc234.3gppnetwork.org", + PAssociatedURI: []string{"sip:234150000000000@ims.mnc015.mcc234.3gppnetwork.org"}, + Transport: "tcp", + LastSIPCode: 200, + }, + smsEvidence: SMSEvidence{Ready: true}, + } +} + +func (environment *fakeEnvironment) record(ctx context.Context, call string) error { + environment.mu.Lock() + environment.calls = append(environment.calls, call) + block := environment.blockAt == call + if remaining := environment.failCounts[call]; remaining > 0 { + environment.failCounts[call] = remaining - 1 + environment.mu.Unlock() + return errors.New(call + " failed") + } + environment.mu.Unlock() + + if block { + environment.blockOnce.Do(func() { close(environment.blocked) }) + <-ctx.Done() + return ctx.Err() + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + return nil + } +} + +func (environment *fakeEnvironment) callsSnapshot() []string { + environment.mu.Lock() + defer environment.mu.Unlock() + return append([]string(nil), environment.calls...) +} + +func (environment *fakeEnvironment) callCount(call string) int { + count := 0 + for _, recorded := range environment.callsSnapshot() { + if recorded == call { + count++ + } + } + return count +} + +func (environment *fakeEnvironment) setFailure(call string, count int) { + environment.mu.Lock() + environment.failCounts[call] = count + environment.mu.Unlock() +} + +type fakeSIM struct{ environment *fakeEnvironment } + +func (fake fakeSIM) ReadIdentity(ctx context.Context, _ string) (SIMIdentity, error) { + if err := fake.environment.record(ctx, "sim.identity"); err != nil { + return SIMIdentity{}, err + } + return fake.environment.identity, nil +} + +type fakeAKA struct{ environment *fakeEnvironment } + +func (fake fakeAKA) CheckReady(ctx context.Context, _ SIMIdentity) (AKAEvidence, error) { + if err := fake.environment.record(ctx, "aka.ready"); err != nil { + return AKAEvidence{}, err + } + return fake.environment.akaEvidence, nil +} + +func (fake fakeAKA) Authenticate(ctx context.Context, _ SIMIdentity, _ AKAChallenge) (AKAResult, error) { + if err := fake.environment.record(ctx, "aka.authenticate"); err != nil { + return AKAResult{}, err + } + return AKAResult{ + RES: []byte{0x01, 0x02, 0x03, 0x04}, + CK: make([]byte, 16), + IK: make([]byte, 16), + }, nil +} + +type fakeRadio struct{ environment *fakeEnvironment } + +func (fake fakeRadio) Snapshot(ctx context.Context, _ string) (RadioSnapshot, error) { + if err := fake.environment.record(ctx, "radio.snapshot"); err != nil { + return RadioSnapshot{}, err + } + return fake.environment.radioSnapshot, nil +} + +func (fake fakeRadio) StopCellularData(ctx context.Context, _ string) error { + return fake.environment.record(ctx, "radio.stop_data") +} + +func (fake fakeRadio) EnterVoWiFiRFOff(ctx context.Context, _ string) error { + return fake.environment.record(ctx, "radio.rf_off") +} + +func (fake fakeRadio) Restore(ctx context.Context, _ string, _ RadioSnapshot) error { + return fake.environment.record(ctx, "radio.restore") +} + +type fakeProxy struct{ environment *fakeEnvironment } + +func (fake fakeProxy) Resolve(ctx context.Context, _ ProxyRequest) (ProxyRoute, error) { + if err := fake.environment.record(ctx, "proxy.resolve"); err != nil { + return ProxyRoute{}, err + } + return fake.environment.proxy, nil +} + +type fakeTunnelProvider struct{ environment *fakeEnvironment } + +func (fake fakeTunnelProvider) Start(ctx context.Context, request TunnelRequest) (TunnelSession, error) { + if err := fake.environment.record(ctx, "tunnel.start"); err != nil { + return nil, err + } + fake.environment.mu.Lock() + fake.environment.tunnelRequests = append(fake.environment.tunnelRequests, request) + fake.environment.mu.Unlock() + return &fakeTunnelSession{environment: fake.environment}, nil +} + +type fakeTunnelSession struct{ environment *fakeEnvironment } + +func (fake *fakeTunnelSession) Evidence() TunnelEvidence { + _ = fake.environment.record(context.Background(), "tunnel.evidence") + return fake.environment.tunnelEvidence +} + +func (fake *fakeTunnelSession) Close(ctx context.Context) error { + return fake.environment.record(ctx, "tunnel.close") +} + +func (fake *fakeTunnelSession) Failures() <-chan error { + return fake.environment.tunnelFailures +} + +type fakeIMSProvider struct{ environment *fakeEnvironment } + +func (fake fakeIMSProvider) Start(ctx context.Context, _ IMSRequest) (IMSSession, error) { + if err := fake.environment.record(ctx, "ims.start"); err != nil { + return nil, err + } + return &fakeIMSSession{environment: fake.environment}, nil +} + +type fakeIMSSession struct{ environment *fakeEnvironment } + +func (fake *fakeIMSSession) Evidence() IMSEvidence { + _ = fake.environment.record(context.Background(), "ims.evidence") + return fake.environment.imsEvidence +} + +func (fake *fakeIMSSession) EnableSMS(ctx context.Context) (SMSEvidence, error) { + if err := fake.environment.record(ctx, "ims.sms"); err != nil { + return SMSEvidence{}, err + } + return fake.environment.smsEvidence, nil +} + +func (fake *fakeIMSSession) Close(ctx context.Context) error { + return fake.environment.record(ctx, "ims.close") +} + +func (fake *fakeIMSSession) Failures() <-chan error { + return fake.environment.imsFailures +} + +type fakePhones struct{ environment *fakeEnvironment } + +func (fake fakePhones) SaveAssociatedNumber(ctx context.Context, record PhoneRecord) error { + if err := fake.environment.record(ctx, "phone.save"); err != nil { + return err + } + fake.environment.mu.Lock() + fake.environment.phoneRecords = append(fake.environment.phoneRecords, record) + fake.environment.mu.Unlock() + return nil +} + +func newTestOrchestrator(t *testing.T, environment *fakeEnvironment, allowMissingAUTH bool) *Orchestrator { + t.Helper() + return newTestOrchestratorWithOptions(t, environment, Options{ + DeviceID: "EC20", + AllowMissingResponderAUTH: allowMissingAUTH, + CleanupTimeout: time.Second, + }) +} + +func newTestOrchestratorWithOptions( + t *testing.T, + environment *fakeEnvironment, + options Options, +) *Orchestrator { + t.Helper() + orchestrator, err := New(Dependencies{ + SIM: fakeSIM{environment}, + AKA: fakeAKA{environment}, + Radio: fakeRadio{environment}, + Proxy: fakeProxy{environment}, + Tunnel: fakeTunnelProvider{environment}, + IMS: fakeIMSProvider{environment}, + Phones: fakePhones{environment}, + }, options) + if err != nil { + t.Fatalf("New() error = %v", err) + } + return orchestrator +} + +func TestEnableKeepsIMSAndNumberWhenSMSCapabilityIsOptional(t *testing.T) { + environment := newFakeEnvironment() + environment.setFailure("ims.sms", 1) + orchestrator := newTestOrchestratorWithOptions(t, environment, Options{ + DeviceID: "EC20", + AllowIMSWithoutSMS: true, + CleanupTimeout: time.Second, + }) + + state, err := orchestrator.Enable(context.Background()) + if err != nil { + t.Fatalf("Enable() error = %v", err) + } + if state.Phase != PhaseIMSReady || !state.Active || !state.TunnelReady || + !state.IMSReady || state.SMSReady { + t.Fatalf("Enable() state = %+v", state) + } + if state.PhoneNumber != "+447700900123" { + t.Fatalf("phone number = %q", state.PhoneNumber) + } + if state.LastReason != "ims_registered_sms_unavailable" || + len(state.Warnings) == 0 { + t.Fatalf("optional SMS evidence = %+v", state) + } + if environment.callCount("ims.close") != 0 || + environment.callCount("tunnel.close") != 0 { + t.Fatal("optional SMS failure tore down a valid IMS registration") + } +} + +func TestEnableUsesEvidenceBackedOrderAndDisableRollsBackInReverse(t *testing.T) { + environment := newFakeEnvironment() + orchestrator := newTestOrchestrator(t, environment, false) + + state, err := orchestrator.Enable(context.Background()) + if err != nil { + t.Fatalf("Enable() error = %v", err) + } + if state.Phase != PhaseSMSReady || + !state.Enabled || + !state.Active || + !state.SIMReady || + !state.AccessReady || + !state.TunnelReady || + !state.IMSReady || + !state.SMSReady { + t.Fatalf("Enable() state = %+v", state) + } + if state.PhoneNumber != "+447700900123" || + state.PhoneNumberSource != PhoneSourceAssociatedMSISDN { + t.Fatalf("phone projection = %q (%q)", state.PhoneNumber, state.PhoneNumberSource) + } + if state.Security.ResponderAUTH != ResponderAUTHVerified || state.Security.HighRisk { + t.Fatalf("security audit = %+v", state.Security) + } + if state.PureAirplanePolicy { + t.Fatal("VoWiFi RF off must not enable the independent pure-airplane policy") + } + + wantEnableCalls := []string{ + "sim.identity", + "aka.ready", + "radio.snapshot", + "radio.rf_off", + "radio.stop_data", + "proxy.resolve", + "tunnel.start", + "tunnel.evidence", + "ims.start", + "ims.evidence", + "phone.save", + "ims.sms", + } + if calls := environment.callsSnapshot(); !reflect.DeepEqual(calls, wantEnableCalls) { + t.Fatalf("enable calls = %#v, want %#v", calls, wantEnableCalls) + } + if len(environment.tunnelRequests) != 1 { + t.Fatalf("tunnel request count = %d", len(environment.tunnelRequests)) + } + request := environment.tunnelRequests[0] + if request.EPDG != "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org" { + t.Fatalf("EPDG = %q", request.EPDG) + } + if request.Proxy.Mode != ProxyModeDirect || request.Security.AllowMissingResponderAUTH { + t.Fatalf("tunnel request = %+v", request) + } + + state, err = orchestrator.Disable(context.Background()) + if err != nil { + t.Fatalf("Disable() error = %v", err) + } + if state.Phase != PhaseIdle || state.Enabled || state.Active || + state.TunnelReady || state.IMSReady || state.SMSReady { + t.Fatalf("Disable() state = %+v", state) + } + if state.PhoneNumber != "+447700900123" { + t.Fatal("disabling the runtime must not erase the ICCID-associated number projection") + } + calls := environment.callsSnapshot() + wantCleanup := []string{"ims.close", "tunnel.close", "radio.restore"} + if !reflect.DeepEqual(calls[len(calls)-len(wantCleanup):], wantCleanup) { + t.Fatalf("cleanup tail = %#v, want %#v", calls, wantCleanup) + } +} + +func TestEnableFailuresCleanUpEveryAcquiredLayer(t *testing.T) { + tests := []struct { + name string + failCall string + mutate func(*fakeEnvironment) + wantError error + wantCleanupTail []string + }{ + {name: "identity", failCall: "sim.identity"}, + {name: "aka", failCall: "aka.ready"}, + {name: "radio snapshot", failCall: "radio.snapshot"}, + { + name: "stop data can partially mutate", + failCall: "radio.stop_data", + wantCleanupTail: []string{"radio.restore"}, + }, + { + name: "rf off", + failCall: "radio.rf_off", + wantCleanupTail: []string{"radio.restore"}, + }, + { + name: "proxy", + failCall: "proxy.resolve", + wantCleanupTail: []string{"radio.restore"}, + }, + { + name: "tunnel start", + failCall: "tunnel.start", + wantCleanupTail: []string{"radio.restore"}, + }, + { + name: "tunnel evidence", + mutate: func(environment *fakeEnvironment) { + environment.tunnelEvidence.Established = false + environment.tunnelEvidence.ResponderAUTH = ResponderAUTHUnknown + }, + wantError: ErrTunnelNotEstablished, + wantCleanupTail: []string{"tunnel.close", "radio.restore"}, + }, + { + name: "IMS start", + failCall: "ims.start", + wantCleanupTail: []string{"tunnel.close", "radio.restore"}, + }, + { + name: "IMS registration evidence", + mutate: func(environment *fakeEnvironment) { + environment.imsEvidence.Registered = false + }, + wantError: ErrIMSNotRegistered, + wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, + }, + { + name: "SMS activation", + failCall: "ims.sms", + wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, + }, + { + name: "SMS evidence", + mutate: func(environment *fakeEnvironment) { + environment.smsEvidence.Ready = false + }, + wantError: ErrSMSNotReady, + wantCleanupTail: []string{"ims.close", "tunnel.close", "radio.restore"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + environment := newFakeEnvironment() + if test.failCall != "" { + environment.setFailure(test.failCall, 1) + } + if test.mutate != nil { + test.mutate(environment) + } + orchestrator := newTestOrchestrator(t, environment, false) + + state, err := orchestrator.Enable(context.Background()) + if err == nil { + t.Fatal("Enable() unexpectedly succeeded") + } + if test.wantError != nil && !errors.Is(err, test.wantError) { + t.Fatalf("Enable() error = %v, want errors.Is(%v)", err, test.wantError) + } + if state.Phase != PhaseFailed || state.Active || + state.TunnelReady || state.IMSReady || state.SMSReady { + t.Fatalf("failed state = %+v", state) + } + if len(test.wantCleanupTail) > 0 { + calls := environment.callsSnapshot() + if len(calls) < len(test.wantCleanupTail) { + t.Fatalf("calls = %#v", calls) + } + tail := calls[len(calls)-len(test.wantCleanupTail):] + if !reflect.DeepEqual(tail, test.wantCleanupTail) { + t.Fatalf("cleanup tail = %#v, want %#v", tail, test.wantCleanupTail) + } + } + }) + } +} + +func TestResponderAUTHPolicyIsStrictByDefaultAndAuditsExplicitCompatibility(t *testing.T) { + t.Run("strict", func(t *testing.T) { + environment := newFakeEnvironment() + environment.tunnelEvidence.ResponderAUTH = ResponderAUTHMissing + orchestrator := newTestOrchestrator(t, environment, false) + + state, err := orchestrator.Enable(context.Background()) + if !errors.Is(err, ErrResponderAUTHRequired) { + t.Fatalf("Enable() error = %v", err) + } + if state.Phase != PhaseFailed || state.Security.HighRisk || + state.Security.CompatibilityOverride { + t.Fatalf("strict security state = %+v", state.Security) + } + }) + + t.Run("explicit compatibility", func(t *testing.T) { + environment := newFakeEnvironment() + environment.tunnelEvidence.ResponderAUTH = ResponderAUTHMissing + orchestrator := newTestOrchestrator(t, environment, true) + + state, err := orchestrator.Enable(context.Background()) + if err != nil { + t.Fatalf("Enable() error = %v", err) + } + if state.Phase != PhaseSMSReady || + !state.Security.HighRisk || + !state.Security.CompatibilityOverride || + state.Security.Level != AuditLevelHigh || + state.Security.Code != AuditCodeMissingResponderAUTH { + t.Fatalf("compatibility security state = %+v", state.Security) + } + if !environment.tunnelRequests[0].Security.AllowMissingResponderAUTH { + t.Fatal("explicit compatibility policy was not passed to the tunnel provider") + } + }) + + t.Run("invalid is never compatible", func(t *testing.T) { + environment := newFakeEnvironment() + environment.tunnelEvidence.ResponderAUTH = ResponderAUTHInvalid + orchestrator := newTestOrchestrator(t, environment, true) + + state, err := orchestrator.Enable(context.Background()) + if !errors.Is(err, ErrResponderAUTHRequired) || state.Phase != PhaseFailed { + t.Fatalf("Enable() = (%+v, %v)", state, err) + } + }) +} + +func TestPhoneNumberIsNeverInferredFromIMSI(t *testing.T) { + environment := newFakeEnvironment() + environment.identity.IMSI = "234159999999999" + environment.imsEvidence.AssociatedMSISDN = "" + environment.imsEvidence.PAssociatedURI = []string{ + "sip:234159999999999@ims.mnc015.mcc234.3gppnetwork.org", + } + orchestrator := newTestOrchestrator(t, environment, false) + + state, err := orchestrator.Enable(context.Background()) + if err != nil { + t.Fatalf("Enable() error = %v", err) + } + if state.PhoneNumber != "" || environment.callCount("phone.save") != 0 { + t.Fatalf("number was inferred: state=%+v records=%+v", state, environment.phoneRecords) + } + if len(state.Warnings) != 1 || !strings.Contains(state.Warnings[0], "not inferred from IMSI") { + t.Fatalf("warnings = %#v", state.Warnings) + } +} + +func TestPhoneStoreFailureDoesNotMisreportOrTearDownWorkingIMS(t *testing.T) { + environment := newFakeEnvironment() + environment.setFailure("phone.save", 1) + orchestrator := newTestOrchestrator(t, environment, false) + + state, err := orchestrator.Enable(context.Background()) + if err != nil { + t.Fatalf("Enable() error = %v", err) + } + if state.Phase != PhaseSMSReady || !state.IMSReady || state.PhoneNumber != "" { + t.Fatalf("state = %+v", state) + } + if len(state.Warnings) != 1 || !strings.Contains(state.Warnings[0], "could not be persisted") { + t.Fatalf("warnings = %#v", state.Warnings) + } +} + +func TestDisableCancelsAnInFlightEnableAndRestoresRadio(t *testing.T) { + environment := newFakeEnvironment() + environment.blockAt = "tunnel.start" + orchestrator := newTestOrchestrator(t, environment, false) + + enableResult := make(chan error, 1) + go func() { + _, err := orchestrator.Enable(context.Background()) + enableResult <- err + }() + + select { + case <-environment.blocked: + case <-time.After(2 * time.Second): + t.Fatal("Enable() did not reach blocking tunnel provider") + } + + disableContext, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + state, err := orchestrator.Disable(disableContext) + if err != nil { + t.Fatalf("Disable() error = %v", err) + } + if state.Phase != PhaseIdle || state.Enabled || state.Active { + t.Fatalf("Disable() state = %+v", state) + } + select { + case err := <-enableResult: + if !errors.Is(err, context.Canceled) { + t.Fatalf("Enable() error = %v, want context.Canceled", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Enable() did not exit after Disable() cancellation") + } + if environment.callCount("radio.restore") != 1 { + t.Fatalf("radio.restore count = %d", environment.callCount("radio.restore")) + } +} + +func TestConcurrentEnableStartsOnlyOneRuntime(t *testing.T) { + environment := newFakeEnvironment() + orchestrator := newTestOrchestrator(t, environment, false) + + const goroutines = 24 + start := make(chan struct{}) + results := make(chan error, goroutines) + var group sync.WaitGroup + for index := 0; index < goroutines; index++ { + group.Add(1) + go func() { + defer group.Done() + <-start + _, err := orchestrator.Enable(context.Background()) + results <- err + }() + } + close(start) + group.Wait() + close(results) + + successes := 0 + alreadyEnabled := 0 + for err := range results { + switch { + case err == nil: + successes++ + case errors.Is(err, ErrAlreadyEnabled): + alreadyEnabled++ + default: + t.Fatalf("unexpected Enable() error = %v", err) + } + } + if successes != 1 || alreadyEnabled != goroutines-1 { + t.Fatalf("successes=%d alreadyEnabled=%d", successes, alreadyEnabled) + } + if environment.callCount("tunnel.start") != 1 { + t.Fatalf("tunnel.start count = %d", environment.callCount("tunnel.start")) + } +} + +func TestRetryAfterFailureCreatesANewAttempt(t *testing.T) { + environment := newFakeEnvironment() + environment.setFailure("tunnel.start", 1) + orchestrator := newTestOrchestrator(t, environment, false) + + first, err := orchestrator.Enable(context.Background()) + if err == nil || first.Phase != PhaseFailed || first.Attempt != 1 { + t.Fatalf("first Enable() = (%+v, %v)", first, err) + } + second, err := orchestrator.Retry(context.Background()) + if err != nil { + t.Fatalf("Retry() error = %v", err) + } + if second.Phase != PhaseSMSReady || second.Attempt != 2 { + t.Fatalf("Retry() state = %+v", second) + } + if environment.callCount("tunnel.start") != 2 { + t.Fatalf("tunnel.start count = %d", environment.callCount("tunnel.start")) + } +} + +func TestReconnectClosesThenRebuildsTheRuntime(t *testing.T) { + environment := newFakeEnvironment() + orchestrator := newTestOrchestrator(t, environment, false) + if _, err := orchestrator.Enable(context.Background()); err != nil { + t.Fatal(err) + } + + state, err := orchestrator.Reconnect(context.Background()) + if err != nil { + t.Fatalf("Reconnect() error = %v", err) + } + if state.Phase != PhaseSMSReady || state.Attempt != 2 { + t.Fatalf("Reconnect() state = %+v", state) + } + if environment.callCount("tunnel.start") != 2 || + environment.callCount("tunnel.close") != 1 || + environment.callCount("radio.restore") != 1 { + t.Fatalf("calls = %#v", environment.callsSnapshot()) + } +} + +// A non-fatal teardown error (for example the network rejecting SIP +// deregistration during IMS close) must not stop a reconnect from rebuilding +// the runtime; Disable still releases the local IMS, tunnel, and radio layers. +func TestReconnectToleratesCleanupFailureAndRebuilds(t *testing.T) { + environment := newFakeEnvironment() + orchestrator := newTestOrchestrator(t, environment, false) + if _, err := orchestrator.Enable(context.Background()); err != nil { + t.Fatal(err) + } + + environment.setFailure("ims.close", 1) + + state, err := orchestrator.Reconnect(context.Background()) + if err != nil { + t.Fatalf("Reconnect() error = %v", err) + } + if state.Phase != PhaseSMSReady || state.Attempt != 2 { + t.Fatalf("Reconnect() state = %+v", state) + } + if environment.callCount("ims.close") != 1 || + environment.callCount("tunnel.close") != 1 || + environment.callCount("radio.restore") != 1 || + environment.callCount("tunnel.start") != 2 { + t.Fatalf("calls = %#v", environment.callsSnapshot()) + } +} + +func TestRuntimeTunnelFailureRevokesReadinessAndCleansEveryLayer(t *testing.T) { + environment := newFakeEnvironment() + environment.tunnelFailures = make(chan error, 1) + orchestrator := newTestOrchestrator(t, environment, false) + if _, err := orchestrator.Enable(context.Background()); err != nil { + t.Fatal(err) + } + + environment.tunnelFailures <- errors.New("ESP relay stopped") + deadline := time.Now().Add(2 * time.Second) + for { + state := orchestrator.State() + if state.Phase == PhaseFailed { + if state.Active || state.TunnelReady || state.IMSReady || state.SMSReady { + t.Fatalf("stale runtime readiness survived failure: %+v", state) + } + if !state.Enabled || state.LastErrorClass != "tunnel_runtime" || + state.LastReason != "runtime_tunnel_failed" || + !strings.Contains(state.LastError, "ESP relay stopped") { + t.Fatalf("runtime failure evidence = %+v", state) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for runtime failure; state = %+v", state) + } + time.Sleep(10 * time.Millisecond) + } + + calls := environment.callsSnapshot() + wantTail := []string{"ims.close", "tunnel.close", "radio.restore"} + if len(calls) < len(wantTail) || + !reflect.DeepEqual(calls[len(calls)-len(wantTail):], wantTail) { + t.Fatalf("runtime failure cleanup tail = %#v", calls) + } +} + +func TestRuntimeIMSFailureRevokesRegistrationEvidence(t *testing.T) { + environment := newFakeEnvironment() + environment.imsFailures = make(chan error, 1) + orchestrator := newTestOrchestrator(t, environment, false) + if _, err := orchestrator.Enable(context.Background()); err != nil { + t.Fatal(err) + } + + environment.imsFailures <- errors.New("registration refresh failed") + deadline := time.Now().Add(2 * time.Second) + for { + state := orchestrator.State() + if state.Phase == PhaseFailed { + if state.TunnelReady || state.IMSReady || state.SMSReady || + state.LastErrorClass != "ims_runtime" || + state.LastReason != "runtime_ims_failed" { + t.Fatalf("IMS runtime failure evidence = %+v", state) + } + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for IMS runtime failure; state = %+v", state) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestSubscriptionPublishesOrderedEvidencePhases(t *testing.T) { + environment := newFakeEnvironment() + orchestrator := newTestOrchestrator(t, environment, false) + updates, unsubscribe := orchestrator.Subscribe(32) + defer unsubscribe() + + if _, err := orchestrator.Enable(context.Background()); err != nil { + t.Fatal(err) + } + + var phases []Phase + deadline := time.After(2 * time.Second) + for { + select { + case state := <-updates: + if len(phases) == 0 || phases[len(phases)-1] != state.Phase { + phases = append(phases, state.Phase) + } + if state.Phase == PhaseSMSReady { + want := []Phase{ + PhaseIdle, + PhaseSIMReady, + PhaseAccessReady, + PhaseTunnelReady, + PhaseIMSReady, + PhaseSMSReady, + } + if !reflect.DeepEqual(phases, want) { + t.Fatalf("phases = %#v, want %#v", phases, want) + } + return + } + case <-deadline: + t.Fatalf("timed out waiting for phases; got %#v", phases) + } + } +} + +func TestCleanupAttemptsEveryLayerAndReportsAllErrors(t *testing.T) { + environment := newFakeEnvironment() + environment.setFailure("ims.sms", 1) + environment.setFailure("ims.close", 1) + environment.setFailure("tunnel.close", 1) + environment.setFailure("radio.restore", 1) + orchestrator := newTestOrchestrator(t, environment, false) + + state, err := orchestrator.Enable(context.Background()) + if err == nil { + t.Fatal("Enable() unexpectedly succeeded") + } + if len(state.CleanupErrors) != 3 { + t.Fatalf("cleanup errors = %#v", state.CleanupErrors) + } + calls := environment.callsSnapshot() + wantTail := []string{"ims.close", "tunnel.close", "radio.restore"} + if !reflect.DeepEqual(calls[len(calls)-3:], wantTail) { + t.Fatalf("cleanup tail = %#v", calls[len(calls)-3:]) + } + for _, text := range []string{"close IMS", "close tunnel", "restore radio"} { + if !strings.Contains(err.Error(), text) { + t.Fatalf("error %q does not contain %q", err, text) + } + } +} + +func TestDisableCleanupWarningStillSettlesIdle(t *testing.T) { + environment := newFakeEnvironment() + orchestrator := newTestOrchestrator(t, environment, false) + if _, err := orchestrator.Enable(context.Background()); err != nil { + t.Fatalf("Enable() error = %v", err) + } + environment.setFailure("ims.close", 1) + + state, err := orchestrator.Disable(context.Background()) + if !errors.Is(err, ErrCleanupIncomplete) { + t.Fatalf("Disable() error = %v, want ErrCleanupIncomplete", err) + } + if state.Phase != PhaseIdle || state.Enabled || state.Active || + state.SIMReady || state.AccessReady || state.TunnelReady || + state.IMSReady || state.SMSReady { + t.Fatalf("Disable() warning state = %+v", state) + } + if state.LastErrorClass != "cleanup_warning" || + state.LastReason != "disabled_with_cleanup_errors" || + len(state.CleanupErrors) != 1 { + t.Fatalf("Disable() warning evidence = %+v", state) + } +} + +func TestNewRejectsMissingProvidersAndInvalidOptions(t *testing.T) { + environment := newFakeEnvironment() + dependencies := Dependencies{ + SIM: fakeSIM{environment}, + AKA: fakeAKA{environment}, + Radio: fakeRadio{environment}, + Proxy: fakeProxy{environment}, + Tunnel: fakeTunnelProvider{environment}, + IMS: fakeIMSProvider{environment}, + Phones: fakePhones{environment}, + } + if _, err := New(Dependencies{}, Options{DeviceID: "EC20"}); err == nil { + t.Fatal("New() accepted missing providers") + } + if _, err := New(dependencies, Options{}); err == nil { + t.Fatal("New() accepted empty device ID") + } +} diff --git a/internal/vowifi/phone.go b/internal/vowifi/phone.go new file mode 100644 index 0000000..8cbf0c3 --- /dev/null +++ b/internal/vowifi/phone.go @@ -0,0 +1,105 @@ +package vowifi + +import ( + "net/url" + "strings" + "unicode" +) + +// ExtractAssociatedMSISDN accepts only identities explicitly associated by +// IMS. It never examines SIMIdentity.IMSI and deliberately rejects a bare SIP +// user that could be an IMSI. +func ExtractAssociatedMSISDN(evidence IMSEvidence) (number string, source string, ok bool) { + if number, ok := normalizeAssociatedIdentity(evidence.AssociatedMSISDN, true); ok { + return number, PhoneSourceAssociatedMSISDN, true + } + for _, associatedURI := range evidence.PAssociatedURI { + if number, ok := normalizeAssociatedIdentity(associatedURI, false); ok { + return number, PhoneSourcePAssociatedURI, true + } + } + return "", "", false +} + +func normalizeAssociatedIdentity(raw string, explicit bool) (string, bool) { + value := strings.TrimSpace(raw) + value = strings.Trim(value, "<>\"'") + if value == "" { + return "", false + } + + lower := strings.ToLower(value) + typedURI := false + switch { + case strings.HasPrefix(lower, "tel:"): + typedURI = true + value = value[len("tel:"):] + case strings.HasPrefix(lower, "sip:"): + typedURI = true + value = value[len("sip:"):] + if at := strings.IndexByte(value, '@'); at >= 0 { + value = value[:at] + } + // A bare, non-E.164 SIP user is commonly an IMSI-derived IMPU. + if !strings.HasPrefix(strings.TrimSpace(value), "+") { + return "", false + } + case strings.HasPrefix(lower, "sips:"): + typedURI = true + value = value[len("sips:"):] + if at := strings.IndexByte(value, '@'); at >= 0 { + value = value[:at] + } + if !strings.HasPrefix(strings.TrimSpace(value), "+") { + return "", false + } + default: + if at := strings.IndexByte(value, '@'); at >= 0 { + value = value[:at] + } else if !explicit { + // P-Associated-URI entries must be typed URIs; accepting arbitrary + // digit strings here risks treating an IMSI as an MSISDN. + return "", false + } + } + + if semicolon := strings.IndexByte(value, ';'); semicolon >= 0 { + if !typedURI { + return "", false + } + value = value[:semicolon] + } + if question := strings.IndexByte(value, '?'); question >= 0 { + if !typedURI { + return "", false + } + value = value[:question] + } + if decoded, err := url.PathUnescape(value); err == nil { + value = decoded + } + + var normalized strings.Builder + for _, character := range strings.TrimSpace(value) { + switch { + case character == '+' && normalized.Len() == 0: + normalized.WriteRune(character) + case character >= '0' && character <= '9': + normalized.WriteRune(character) + case unicode.IsSpace(character), character == '-', character == '(', character == ')': + // Human formatting is harmless once the identity source is trusted. + default: + return "", false + } + } + + number := normalized.String() + if !strings.HasPrefix(number, "+") { + return "", false + } + digits := strings.TrimPrefix(number, "+") + if !isNDigits(digits, 5, 15) { + return "", false + } + return number, true +} diff --git a/internal/vowifi/phone_test.go b/internal/vowifi/phone_test.go new file mode 100644 index 0000000..9b5e3f4 --- /dev/null +++ b/internal/vowifi/phone_test.go @@ -0,0 +1,160 @@ +package vowifi + +import "testing" + +func TestExtractAssociatedMSISDN(t *testing.T) { + tests := []struct { + name string + evidence IMSEvidence + wantNumber string + wantSource string + wantOK bool + }{ + { + name: "explicit associated identity with IMS domain", + evidence: IMSEvidence{ + AssociatedMSISDN: "+447700900123@ims.mnc015.mcc234.3gppnetwork.org", + }, + wantNumber: "+447700900123", + wantSource: PhoneSourceAssociatedMSISDN, + wantOK: true, + }, + { + name: "tel P-Associated-URI", + evidence: IMSEvidence{ + PAssociatedURI: []string{""}, + }, + wantNumber: "+447700900123", + wantSource: PhoneSourcePAssociatedURI, + wantOK: true, + }, + { + name: "SIP E164 P-Associated-URI", + evidence: IMSEvidence{ + PAssociatedURI: []string{ + "sip:234150000000000@ims.mnc015.mcc234.3gppnetwork.org", + "", + }, + }, + wantNumber: "+447700900123", + wantSource: PhoneSourcePAssociatedURI, + wantOK: true, + }, + { + name: "percent encoded plus", + evidence: IMSEvidence{ + PAssociatedURI: []string{"tel:%2B447700900123"}, + }, + wantNumber: "+447700900123", + wantSource: PhoneSourcePAssociatedURI, + wantOK: true, + }, + { + name: "reject IMSI-shaped SIP IMPU", + evidence: IMSEvidence{ + PAssociatedURI: []string{ + "sip:234150000000000@ims.mnc015.mcc234.3gppnetwork.org", + }, + }, + wantOK: false, + }, + { + name: "reject arbitrary untyped P-Associated value", + evidence: IMSEvidence{ + PAssociatedURI: []string{"+447700900123"}, + }, + wantOK: false, + }, + { + name: "reject command characters", + evidence: IMSEvidence{ + AssociatedMSISDN: "+447700900123;AT+CFUN=0", + }, + wantOK: false, + }, + { + name: "reject overlong E164", + evidence: IMSEvidence{ + AssociatedMSISDN: "+1234567890123456", + }, + wantOK: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + number, source, ok := ExtractAssociatedMSISDN(test.evidence) + if number != test.wantNumber || source != test.wantSource || ok != test.wantOK { + t.Fatalf( + "ExtractAssociatedMSISDN() = (%q, %q, %t), want (%q, %q, %t)", + number, + source, + ok, + test.wantNumber, + test.wantSource, + test.wantOK, + ) + } + }) + } +} + +func TestDeriveEPDGUsesExplicitPLMNAndNeverIMSIHeuristics(t *testing.T) { + tests := []struct { + name string + identity SIMIdentity + want string + wantErr bool + }{ + { + name: "two digit MNC is padded", + identity: SIMIdentity{ + ICCID: "one", + IMSI: "untrusted-for-plmn", + HomeMCC: "234", + HomeMNC: "15", + }, + want: "epdg.epc.mnc015.mcc234.pub.3gppnetwork.org", + }, + { + name: "three digit MNC is preserved", + identity: SIMIdentity{ + ICCID: "one", + HomeMCC: "310", + HomeMNC: "260", + }, + want: "epdg.epc.mnc260.mcc310.pub.3gppnetwork.org", + }, + { + name: "explicit endpoint", + identity: SIMIdentity{ + ICCID: "one", + HomeMCC: "234", + HomeMNC: "15", + EPDG: "EPDG.EXAMPLE.NET", + }, + want: "epdg.example.net", + }, + { + name: "missing explicit MNC is not guessed from IMSI", + identity: SIMIdentity{ + ICCID: "one", + IMSI: "234150000000000", + HomeMCC: "234", + }, + wantErr: true, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + epdg, err := DeriveEPDG(test.identity) + if (err != nil) != test.wantErr { + t.Fatalf("DeriveEPDG() error = %v, wantErr %t", err, test.wantErr) + } + if epdg != test.want { + t.Fatalf("DeriveEPDG() = %q, want %q", epdg, test.want) + } + }) + } +} diff --git a/internal/vowifi/runtime/manager.go b/internal/vowifi/runtime/manager.go new file mode 100644 index 0000000..6355b00 --- /dev/null +++ b/internal/vowifi/runtime/manager.go @@ -0,0 +1,369 @@ +// Package runtime owns the long-lived VoWiFi orchestrators used by the +// service. It keeps HTTP requests short while preserving every evidence-backed +// state transition through the supplied state callback. +package runtime + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + "vocat/internal/vowifi" +) + +var ( + ErrNotRegistered = errors.New("vowifi runtime: device is not registered") + ErrOperationInProgress = errors.New("vowifi runtime: an operation is already in progress") + ErrClosed = errors.New("vowifi runtime: manager is closed") +) + +const defaultOperationTimeout = 2 * time.Minute + +type StateHandler func(context.Context, vowifi.State) error +type OrchestratorFactory func(context.Context, string) (*vowifi.Orchestrator, error) + +type Options struct { + Logger *slog.Logger + OperationTimeout time.Duration + OnState StateHandler + Factory OrchestratorFactory +} + +type Manager struct { + ctx context.Context + cancel context.CancelFunc + logger *slog.Logger + operationTimeout time.Duration + onState StateHandler + factory OrchestratorFactory + + mu sync.Mutex + closed bool + entries map[string]*entry + wg sync.WaitGroup +} + +type entry struct { + orchestrator *vowifi.Orchestrator + busy bool + reconnectPending bool + stopWatch func() +} + +func New(options Options) *Manager { + if options.Logger == nil { + options.Logger = slog.Default() + } + if options.OperationTimeout <= 0 { + options.OperationTimeout = defaultOperationTimeout + } + ctx, cancel := context.WithCancel(context.Background()) + return &Manager{ + ctx: ctx, + cancel: cancel, + logger: options.Logger, + operationTimeout: options.OperationTimeout, + onState: options.OnState, + factory: options.Factory, + entries: make(map[string]*entry), + } +} + +// Ensure registers a runtime for deviceID on demand. This keeps device +// configuration and runtime lifecycle in sync when a modem is added after the +// service has already started. +func (manager *Manager) Ensure(ctx context.Context, deviceID string) error { + if ctx == nil { + ctx = context.Background() + } + manager.mu.Lock() + if manager.closed { + manager.mu.Unlock() + return ErrClosed + } + if _, exists := manager.entries[deviceID]; exists { + manager.mu.Unlock() + return nil + } + if manager.factory == nil { + manager.mu.Unlock() + return ErrNotRegistered + } + + // The factory is called while holding the manager lock so concurrent status + // and enable requests cannot create duplicate runtimes for the same device. + orchestrator, err := manager.factory(ctx, deviceID) + if err != nil { + manager.mu.Unlock() + return err + } + if orchestrator == nil { + manager.mu.Unlock() + return errors.New("vowifi runtime: factory returned a nil orchestrator") + } + state := orchestrator.State() + if state.DeviceID != deviceID { + manager.mu.Unlock() + _ = orchestrator.Close(context.Background()) + return fmt.Errorf( + "vowifi runtime: factory returned device %q for %q", + state.DeviceID, + deviceID, + ) + } + states, stopWatch := orchestrator.Subscribe(8) + manager.entries[deviceID] = &entry{ + orchestrator: orchestrator, + stopWatch: stopWatch, + } + manager.wg.Add(1) + manager.mu.Unlock() + + go manager.watch(deviceID, states) + return nil +} + +func (manager *Manager) Register(orchestrator *vowifi.Orchestrator) error { + if orchestrator == nil { + return errors.New("vowifi runtime: orchestrator is nil") + } + state := orchestrator.State() + if state.DeviceID == "" { + return errors.New("vowifi runtime: orchestrator device ID is empty") + } + + manager.mu.Lock() + if manager.closed { + manager.mu.Unlock() + return ErrClosed + } + if _, exists := manager.entries[state.DeviceID]; exists { + manager.mu.Unlock() + return fmt.Errorf("vowifi runtime: device %q is already registered", state.DeviceID) + } + states, stopWatch := orchestrator.Subscribe(8) + item := &entry{ + orchestrator: orchestrator, + stopWatch: stopWatch, + } + manager.entries[state.DeviceID] = item + manager.wg.Add(1) + manager.mu.Unlock() + + go manager.watch(state.DeviceID, states) + return nil +} + +func (manager *Manager) State(deviceID string) (vowifi.State, error) { + manager.mu.Lock() + item := manager.entries[deviceID] + closed := manager.closed + manager.mu.Unlock() + if item == nil { + if closed { + return vowifi.State{}, ErrClosed + } + if err := manager.Ensure(manager.ctx, deviceID); err != nil { + return vowifi.State{}, err + } + manager.mu.Lock() + item = manager.entries[deviceID] + manager.mu.Unlock() + } + return item.orchestrator.State(), nil +} + +// RequestEnabled queues an enable or disable transaction and returns +// immediately. Callers observe progress through State; provider errors are +// persisted in the orchestrator state instead of being lost with an HTTP +// request context. +func (manager *Manager) RequestEnabled(deviceID string, enabled bool) (vowifi.State, error) { + if err := manager.Ensure(manager.ctx, deviceID); err != nil { + return vowifi.State{}, err + } + return manager.startOperation(deviceID, false, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error { + if enabled { + _, err := orchestrator.Enable(ctx) + return err + } + _, err := orchestrator.Disable(ctx) + return err + }) +} + +func (manager *Manager) RequestReconnect(deviceID string) (vowifi.State, error) { + if err := manager.Ensure(manager.ctx, deviceID); err != nil { + return vowifi.State{}, err + } + return manager.startOperation(deviceID, true, func(ctx context.Context, orchestrator *vowifi.Orchestrator) error { + _, err := orchestrator.Reconnect(ctx) + return err + }) +} + +func (manager *Manager) SendSMS( + ctx context.Context, + deviceID string, + request vowifi.SMSSubmitRequest, +) (vowifi.SMSSubmitResult, error) { + if err := manager.Ensure(ctx, deviceID); err != nil { + return vowifi.SMSSubmitResult{}, err + } + manager.mu.Lock() + if manager.closed { + manager.mu.Unlock() + return vowifi.SMSSubmitResult{}, ErrClosed + } + item := manager.entries[deviceID] + manager.mu.Unlock() + if item == nil { + return vowifi.SMSSubmitResult{}, ErrNotRegistered + } + return item.orchestrator.SendSMS(ctx, request) +} + +func (manager *Manager) startOperation( + deviceID string, + coalesceReconnect bool, + operation func(context.Context, *vowifi.Orchestrator) error, +) (vowifi.State, error) { + manager.mu.Lock() + if manager.closed { + manager.mu.Unlock() + return vowifi.State{}, ErrClosed + } + item := manager.entries[deviceID] + if item == nil { + manager.mu.Unlock() + return vowifi.State{}, ErrNotRegistered + } + if item.busy { + state := item.orchestrator.State() + if coalesceReconnect { + // Route changes and repeated reconnect clicks only need the latest + // result. Keep one pending reconnect behind the active lifecycle + // operation instead of rejecting the request or running two modem/ + // tunnel transactions concurrently. + item.reconnectPending = true + manager.mu.Unlock() + return state, nil + } + manager.mu.Unlock() + return state, ErrOperationInProgress + } + item.busy = true + manager.wg.Add(1) + manager.mu.Unlock() + + go manager.runOperations(deviceID, item, operation) + return item.orchestrator.State(), nil +} + +func (manager *Manager) runOperations( + deviceID string, + item *entry, + operation func(context.Context, *vowifi.Orchestrator) error, +) { + defer manager.wg.Done() + for { + ctx, cancel := context.WithTimeout(manager.ctx, manager.operationTimeout) + err := operation(ctx, item.orchestrator) + cancel() + if err != nil && + !errors.Is(err, context.Canceled) && + !errors.Is(err, vowifi.ErrAlreadyEnabled) { + manager.logger.Warn( + "VoWiFi operation failed", + "device_id", deviceID, + "error", err, + ) + } + manager.mu.Lock() + if manager.closed || !item.reconnectPending { + item.busy = false + manager.mu.Unlock() + return + } + item.reconnectPending = false + manager.mu.Unlock() + + // Read the route only when this runs. If the user bound, unbound, then + // rebound while busy, the single reconnect uses the final persisted + // binding instead of replaying stale intermediate routes. + operation = func(ctx context.Context, orchestrator *vowifi.Orchestrator) error { + _, err := orchestrator.Reconnect(ctx) + return err + } + } +} + +func (manager *Manager) watch(deviceID string, states <-chan vowifi.State) { + defer manager.wg.Done() + for { + select { + case <-manager.ctx.Done(): + return + case state, ok := <-states: + if !ok { + return + } + if manager.onState == nil { + continue + } + ctx, cancel := context.WithTimeout(manager.ctx, 5*time.Second) + err := manager.onState(ctx, state) + cancel() + if err != nil && !errors.Is(err, context.Canceled) { + manager.logger.Error( + "persist VoWiFi state", + "device_id", deviceID, + "phase", state.Phase, + "error", err, + ) + } + } + } +} + +func (manager *Manager) Close(ctx context.Context) error { + if ctx == nil { + ctx = context.Background() + } + manager.mu.Lock() + if manager.closed { + manager.mu.Unlock() + return nil + } + manager.closed = true + manager.cancel() + items := make([]*entry, 0, len(manager.entries)) + for _, item := range manager.entries { + items = append(items, item) + } + manager.mu.Unlock() + + var closeErrors []error + for _, item := range items { + if item.stopWatch != nil { + item.stopWatch() + } + if err := item.orchestrator.Close(ctx); err != nil { + closeErrors = append(closeErrors, err) + } + } + + done := make(chan struct{}) + go func() { + manager.wg.Wait() + close(done) + }() + select { + case <-ctx.Done(): + closeErrors = append(closeErrors, ctx.Err()) + case <-done: + } + return errors.Join(closeErrors...) +} diff --git a/internal/vowifi/runtime/manager_test.go b/internal/vowifi/runtime/manager_test.go new file mode 100644 index 0000000..75724e6 --- /dev/null +++ b/internal/vowifi/runtime/manager_test.go @@ -0,0 +1,255 @@ +package runtime + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "vocat/internal/vowifi" +) + +type fakeSIM struct{} + +func (fakeSIM) ReadIdentity(context.Context, string) (vowifi.SIMIdentity, error) { + return vowifi.SIMIdentity{ + ICCID: "8944100000000000000", + HomeMCC: "234", + HomeMNC: "15", + HomeCountryCode: "GB", + }, nil +} + +type fakeAKA struct{} + +func (fakeAKA) CheckReady(context.Context, vowifi.SIMIdentity) (vowifi.AKAEvidence, error) { + return vowifi.AKAEvidence{Ready: true, Application: "USIM"}, nil +} + +func (fakeAKA) Authenticate(context.Context, vowifi.SIMIdentity, vowifi.AKAChallenge) (vowifi.AKAResult, error) { + return vowifi.AKAResult{}, nil +} + +type fakeRadio struct{} + +func (fakeRadio) Snapshot(context.Context, string) (vowifi.RadioSnapshot, error) { + return vowifi.RadioSnapshot{CellularDataEnabled: true, OperatingMode: 1}, nil +} +func (fakeRadio) StopCellularData(context.Context, string) error { return nil } +func (fakeRadio) EnterVoWiFiRFOff(context.Context, string) error { return nil } +func (fakeRadio) Restore(context.Context, string, vowifi.RadioSnapshot) error { + return nil +} + +type fakeProxy struct{} + +func (fakeProxy) Resolve(context.Context, vowifi.ProxyRequest) (vowifi.ProxyRoute, error) { + return vowifi.ProxyRoute{Mode: vowifi.ProxyModeDirect}, nil +} + +type fakeTunnelProvider struct{} +type fakeTunnelSession struct{} + +func (fakeTunnelProvider) Start(context.Context, vowifi.TunnelRequest) (vowifi.TunnelSession, error) { + return fakeTunnelSession{}, nil +} +func (fakeTunnelSession) Evidence() vowifi.TunnelEvidence { + return vowifi.TunnelEvidence{ + Established: true, + Name: "xfrm-test", + ResponderAUTH: vowifi.ResponderAUTHVerified, + } +} +func (fakeTunnelSession) Close(context.Context) error { return nil } + +type fakeIMSProvider struct{} +type fakeIMSSession struct{} + +func (fakeIMSProvider) Start(context.Context, vowifi.IMSRequest) (vowifi.IMSSession, error) { + return fakeIMSSession{}, nil +} +func (fakeIMSSession) Evidence() vowifi.IMSEvidence { + return vowifi.IMSEvidence{ + Registered: true, + RegistrationState: "registered", + AssociatedMSISDN: "+447700900123", + } +} +func (fakeIMSSession) EnableSMS(context.Context) (vowifi.SMSEvidence, error) { + return vowifi.SMSEvidence{Ready: true}, nil +} +func (fakeIMSSession) Close(context.Context) error { return nil } + +type fakePhones struct{} + +func (fakePhones) SaveAssociatedNumber(context.Context, vowifi.PhoneRecord) error { + return nil +} + +func testOrchestrator(t *testing.T, id string) *vowifi.Orchestrator { + t.Helper() + orchestrator, err := vowifi.New(vowifi.Dependencies{ + SIM: fakeSIM{}, + AKA: fakeAKA{}, + Radio: fakeRadio{}, + Proxy: fakeProxy{}, + Tunnel: fakeTunnelProvider{}, + IMS: fakeIMSProvider{}, + Phones: fakePhones{}, + }, vowifi.Options{DeviceID: id}) + if err != nil { + t.Fatal(err) + } + return orchestrator +} + +func TestManagerRunsAndPublishesEnable(t *testing.T) { + var mu sync.Mutex + var states []vowifi.State + manager := New(Options{ + OperationTimeout: time.Second, + OnState: func(_ context.Context, state vowifi.State) error { + mu.Lock() + states = append(states, state) + mu.Unlock() + return nil + }, + }) + t.Cleanup(func() { + _ = manager.Close(context.Background()) + }) + if err := manager.Register(testOrchestrator(t, "ec20")); err != nil { + t.Fatal(err) + } + if _, err := manager.RequestEnabled("ec20", true); err != nil { + t.Fatal(err) + } + + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + state, err := manager.State("ec20") + if err != nil { + t.Fatal(err) + } + if state.Phase == vowifi.PhaseSMSReady { + if state.PhoneNumber != "+447700900123" { + t.Fatalf("phone number = %q", state.PhoneNumber) + } + mu.Lock() + published := len(states) + mu.Unlock() + if published > 0 { + return + } + } + time.Sleep(time.Millisecond) + } + t.Fatal("enable did not finish") +} + +func TestManagerRejectsUnknownDevice(t *testing.T) { + manager := New(Options{}) + t.Cleanup(func() { + _ = manager.Close(context.Background()) + }) + if _, err := manager.RequestEnabled("missing", true); !errors.Is(err, ErrNotRegistered) { + t.Fatalf("error = %v", err) + } +} + +func TestManagerCreatesRuntimeOnDemand(t *testing.T) { + created := 0 + manager := New(Options{ + Factory: func(_ context.Context, deviceID string) (*vowifi.Orchestrator, error) { + created++ + return testOrchestrator(t, deviceID), nil + }, + }) + t.Cleanup(func() { + _ = manager.Close(context.Background()) + }) + + state, err := manager.State("hot-added-ec20") + if err != nil { + t.Fatal(err) + } + if state.DeviceID != "hot-added-ec20" || created != 1 { + t.Fatalf("state = %#v, created = %d", state, created) + } + if _, err := manager.RequestEnabled("hot-added-ec20", true); err != nil { + t.Fatal(err) + } + if _, err := manager.State("hot-added-ec20"); err != nil { + t.Fatal(err) + } + if created != 1 { + t.Fatalf("factory calls = %d", created) + } +} + +func TestManagerCoalescesReconnectWhileLifecycleOperationIsBusy(t *testing.T) { + manager := New(Options{OperationTimeout: time.Second}) + t.Cleanup(func() { _ = manager.Close(context.Background()) }) + if err := manager.Register(testOrchestrator(t, "ec20")); err != nil { + t.Fatal(err) + } + if _, err := manager.RequestEnabled("ec20", true); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + manager.mu.Lock() + busy := manager.entries["ec20"].busy + manager.mu.Unlock() + if !busy { + break + } + time.Sleep(time.Millisecond) + } + before := manager.entries["ec20"].orchestrator.State() + if before.Phase != vowifi.PhaseSMSReady { + t.Fatalf("initial phase = %s", before.Phase) + } + + started := make(chan struct{}) + release := make(chan struct{}) + if _, err := manager.startOperation("ec20", false, func(context.Context, *vowifi.Orchestrator) error { + close(started) + <-release + return nil + }); err != nil { + t.Fatal(err) + } + <-started + if _, err := manager.RequestReconnect("ec20"); err != nil { + t.Fatalf("queued reconnect error = %v", err) + } + // Repeated route changes collapse into the same pending reconnect. + if _, err := manager.RequestReconnect("ec20"); err != nil { + t.Fatalf("second queued reconnect error = %v", err) + } + if _, err := manager.RequestEnabled("ec20", true); !errors.Is(err, ErrOperationInProgress) { + t.Fatalf("non-reconnect operation error = %v, want ErrOperationInProgress", err) + } + manager.mu.Lock() + pending := manager.entries["ec20"].reconnectPending + manager.mu.Unlock() + if !pending { + t.Fatal("reconnect was not queued") + } + close(release) + + deadline = time.Now().Add(time.Second) + for time.Now().Before(deadline) { + manager.mu.Lock() + busy := manager.entries["ec20"].busy + manager.mu.Unlock() + state := manager.entries["ec20"].orchestrator.State() + if !busy && state.Phase == vowifi.PhaseSMSReady && state.Sequence > before.Sequence { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("queued reconnect did not run after the active operation") +} diff --git a/internal/vowifi/types.go b/internal/vowifi/types.go new file mode 100644 index 0000000..ec9ca43 --- /dev/null +++ b/internal/vowifi/types.go @@ -0,0 +1,445 @@ +package vowifi + +import ( + "context" + "errors" + "fmt" + "strings" + "time" +) + +// Phase is an evidence-backed VoWiFi lifecycle phase. A requested or enabled +// policy is deliberately not a phase: callers must inspect the concrete +// readiness fields before claiming that a carrier service is available. +type Phase string + +const ( + PhaseIdle Phase = "idle" + PhaseSIMReady Phase = "sim_ready" + PhaseAccessReady Phase = "access_ready" + PhaseTunnelReady Phase = "tunnel_ready" + PhaseIMSReady Phase = "ims_ready" + PhaseSMSReady Phase = "sms_ready" + PhaseFailed Phase = "failed" + PhaseStopping Phase = "stopping" +) + +// ResponderAUTHStatus records the evidence returned by the IKE implementation. +// Unknown and invalid are never accepted. Missing may only be accepted by an +// explicit compatibility policy and is then exposed as a high-risk audit. +type ResponderAUTHStatus string + +const ( + ResponderAUTHUnknown ResponderAUTHStatus = "unknown" + ResponderAUTHVerified ResponderAUTHStatus = "verified" + ResponderAUTHMissing ResponderAUTHStatus = "missing" + ResponderAUTHInvalid ResponderAUTHStatus = "invalid" +) + +const ( + AuditLevelHigh = "high" + AuditCodeMissingResponderAUTH = "missing_responder_auth_allowed" + PhoneSourceAssociatedMSISDN = "ims_associated_msisdn" + PhoneSourcePAssociatedURI = "ims_p_associated_uri" + ProxyModeDirect ProxyMode = "direct" + ProxyModeSOCKS5 ProxyMode = "socks5" +) + +var ( + ErrAlreadyEnabled = errors.New("vowifi: already enabled") + ErrNotRunning = errors.New("vowifi: not running") + ErrRetryRequiresFailure = errors.New("vowifi: retry requires failed state") + ErrInvalidIdentity = errors.New("vowifi: invalid SIM identity") + ErrTunnelNotEstablished = errors.New("vowifi: tunnel is not established") + ErrIMSNotRegistered = errors.New("vowifi: IMS is not registered") + ErrSMSNotReady = errors.New("vowifi: SMS over IMS is not ready") + ErrEAPAuthenticationRejected = errors.New("vowifi: EAP-AKA authentication rejected") + ErrResponderAUTHRequired = errors.New("vowifi: verified IKE responder AUTH is required") + // ErrCleanupIncomplete marks a teardown that released its local IMS, tunnel, + // and radio resources but hit a non-fatal network-side error (for example a + // rejected SIP deregistration). Reconnect treats it as best-effort and + // still rebuilds the runtime instead of wedging in the failed state. + ErrCleanupIncomplete = errors.New("vowifi: cleanup incomplete") +) + +// SecurityAudit is safe to expose through a status API. It contains no keying +// material, identities, proxy credentials, or raw IKE payloads. +type SecurityAudit struct { + ResponderAUTH ResponderAUTHStatus `json:"responder_auth"` + CompatibilityOverride bool `json:"compatibility_override"` + HighRisk bool `json:"high_risk"` + Level string `json:"level,omitempty"` + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` + IKEEncryption string `json:"ike_encryption,omitempty"` + IKEIntegrity string `json:"ike_integrity,omitempty"` + IKEDHGroup string `json:"ike_dh_group,omitempty"` + ESPEncryption string `json:"esp_encryption,omitempty"` + ESPIntegrity string `json:"esp_integrity,omitempty"` +} + +// State is an immutable snapshot when returned by Orchestrator.State or a +// subscription. Enabled means desired policy; Active means a tunnel session +// exists. Neither is proof of IMS registration. +type State struct { + DeviceID string `json:"device_id"` + Phase Phase `json:"phase"` + Enabled bool `json:"enabled"` + Active bool `json:"active"` + SIMReady bool `json:"sim_ready"` + AccessReady bool `json:"access_ready"` + TunnelReady bool `json:"tunnel_ready"` + IMSReady bool `json:"ims_ready"` + SMSReady bool `json:"sms_ready"` + PureAirplanePolicy bool `json:"pure_airplane_policy"` + HomeMCC string `json:"home_mcc,omitempty"` + HomeMNC string `json:"home_mnc,omitempty"` + EPDG string `json:"epdg,omitempty"` + ProxyMode ProxyMode `json:"proxy_mode,omitempty"` + ProxyID string `json:"proxy_id,omitempty"` + TunnelName string `json:"tunnel_name,omitempty"` + DataplaneMode string `json:"dataplane_mode,omitempty"` + IMSRegistration string `json:"ims_registration,omitempty"` + PhoneNumber string `json:"phone_number,omitempty"` + PhoneNumberSource string `json:"phone_number_source,omitempty"` + LastErrorClass string `json:"last_error_class,omitempty"` + LastError string `json:"last_error,omitempty"` + LastReason string `json:"last_reason,omitempty"` + Warnings []string `json:"warnings,omitempty"` + CleanupErrors []string `json:"cleanup_errors,omitempty"` + Security SecurityAudit `json:"security"` + Attempt uint64 `json:"attempt"` + Sequence uint64 `json:"sequence"` + StartedAt *time.Time `json:"started_at,omitempty"` + UpdatedAt time.Time `json:"updated_at"` +} + +func (state State) clone() State { + state.Warnings = append([]string(nil), state.Warnings...) + state.CleanupErrors = append([]string(nil), state.CleanupErrors...) + if state.StartedAt != nil { + startedAt := *state.StartedAt + state.StartedAt = &startedAt + } + return state +} + +// SIMIdentity contains only information required by providers. It is never +// copied wholesale into State, which avoids accidentally exposing IMSI/ICCID. +// HomeMCC and HomeMNC must be supplied by the SIM reader; the orchestrator does +// not guess MNC length or a phone number from IMSI. +type SIMIdentity struct { + ICCID string + IMSI string + IMEI string + HomeMCC string + HomeMNC string + HomeCountryCode string + EPDG string + // SMSC is the TS-Service-Centre address used to build SMS-over-IMS + // RP-DATA. It is optional during identity discovery, but IMS submission + // requires it. + SMSC string +} + +func (identity SIMIdentity) validate() error { + if strings.TrimSpace(identity.ICCID) == "" { + return fmt.Errorf("%w: ICCID is empty", ErrInvalidIdentity) + } + if !isNDigits(identity.HomeMCC, 3, 3) { + return fmt.Errorf("%w: home MCC must contain three digits", ErrInvalidIdentity) + } + if !isNDigits(identity.HomeMNC, 2, 3) { + return fmt.Errorf("%w: home MNC must contain two or three digits", ErrInvalidIdentity) + } + return nil +} + +// AKAEvidence proves that a usable USIM/ISIM AKA application was opened. It +// intentionally contains no secret material or authentication vectors. +type AKAEvidence struct { + Ready bool + Application string +} + +// AKAChallenge is the exact UMTS AKA input carried by EAP-AKA. Fixed-size +// fields prevent a provider from silently accepting truncated RAND or AUTN +// values. +type AKAChallenge struct { + RAND [16]byte + AUTN [16]byte +} + +// AKAResult is either a successful USIM authentication vector (RES/CK/IK), or +// synchronization-failure evidence (AUTS). Implementations must never place +// CK, IK, RAND, AUTN, or AUTS in errors or logs. +type AKAResult struct { + RES []byte + CK []byte + IK []byte + AUTS []byte + SynchronizationFailure bool +} + +type RadioSnapshot struct { + CellularDataEnabled bool + OperatingMode int + PureAirplanePolicy bool +} + +type ProxyMode string + +// ProxyRoute may carry credentials to TunnelProvider, but State only receives +// Mode and ID. Provider implementations must not include Password in errors. +type ProxyRoute struct { + Mode ProxyMode + ID string + Address string + Username string + Password string +} + +type ProxyRequest struct { + DeviceID string + HomeMCC string + HomeMNC string + CountryCode string +} + +type TunnelSecurityPolicy struct { + AllowMissingResponderAUTH bool +} + +type TunnelRequest struct { + DeviceID string + Identity SIMIdentity + EPDG string + Proxy ProxyRoute + AKA AKAProvider + Security TunnelSecurityPolicy +} + +type TunnelEvidence struct { + Established bool + Name string + DataplaneMode string + LocalIPv4 string + LocalIPv6 string + PCSCF []string + ResponderAUTH ResponderAUTHStatus + IKEEncryption string + IKEIntegrity string + IKEDHGroup string + ESPEncryption string + ESPIntegrity string +} + +type IMSRequest struct { + DeviceID string + Identity SIMIdentity + Tunnel TunnelSession +} + +type IMSEvidence struct { + Registered bool + RegistrationState string + AssociatedMSISDN string + PAssociatedURI []string + AssociatedIdentities []string + RegisteredContact string + ServiceRoute []string + Transport string + LastSIPCode int + SecurityMode string + SecurityVerified bool +} + +type SMSEvidence struct { + Ready bool +} + +type SMSSubmitRequest struct { + Recipient string + Text string +} + +type SMSSubmitPart struct { + Part int `json:"part"` + Total int `json:"total"` + Reference int `json:"reference"` + SIPCode int `json:"sipCode"` + Accepted bool `json:"accepted"` + SubmittedAt time.Time `json:"submittedAt"` + SubmissionStatus string `json:"submissionStatus"` +} + +// SMSSubmitResult proves acceptance by the IMS SIP endpoint. It does not +// claim that the recipient read the message. +type SMSSubmitResult struct { + To string `json:"to"` + Encoding string `json:"encoding"` + SubmittedAt time.Time `json:"submittedAt"` + PartsTotal int `json:"partsTotal"` + PartsAttempted int `json:"partsAttempted"` + PartsAccepted int `json:"partsAccepted"` + AllPartsAccepted bool `json:"allPartsAccepted"` + ConcatReference *int `json:"concatReference,omitempty"` + SubmissionStatus string `json:"submissionStatus"` + DeliveryConfirmed bool `json:"deliveryConfirmed"` + PartResults []SMSSubmitPart `json:"partResults"` +} + +type PhoneRecord struct { + ICCID string + Number string + Source string + UpdatedAt time.Time +} + +// SIMIdentityReader reads live SIM identity and home PLMN information. +type SIMIdentityReader interface { + ReadIdentity(context.Context, string) (SIMIdentity, error) +} + +// SMSCenterReader optionally supplies the SIM-configured service-centre +// address needed for mobile-originated SMS over IMS. +type SMSCenterReader interface { + ReadSMSCenter(context.Context, string) (string, error) +} + +// AKAProvider validates AKA availability and is passed to TunnelProvider so +// the latter can answer EAP-AKA challenges without exporting SIM secrets. +type AKAProvider interface { + CheckReady(context.Context, SIMIdentity) (AKAEvidence, error) + Authenticate(context.Context, SIMIdentity, AKAChallenge) (AKAResult, error) +} + +// RadioController owns the host/modem radio projection. EnterVoWiFiRFOff must +// not toggle the independent pure-airplane policy; Restore must return to the +// captured pre-transaction state. +type RadioController interface { + Snapshot(context.Context, string) (RadioSnapshot, error) + StopCellularData(context.Context, string) error + EnterVoWiFiRFOff(context.Context, string) error + Restore(context.Context, string, RadioSnapshot) error +} + +// ProxyResolver maps the SIM home country/MCC to either a SOCKS5 route or +// direct transport. +type ProxyResolver interface { + Resolve(context.Context, ProxyRequest) (ProxyRoute, error) +} + +// TunnelProvider establishes SWu/IKEv2/IPsec. The Start context bounds setup; +// a returned session remains alive until Close is called. +type TunnelProvider interface { + Start(context.Context, TunnelRequest) (TunnelSession, error) +} + +type TunnelSession interface { + Evidence() TunnelEvidence + Close(context.Context) error +} + +// RuntimeFailureNotifier is an optional long-lived session capability. A +// provider sends only terminal failures; normal Close must not send or close +// the channel. This lets the orchestrator revoke stale readiness evidence. +type RuntimeFailureNotifier interface { + Failures() <-chan error +} + +// IMSProvider registers IMS over an established tunnel. The Start context +// bounds setup; a returned session remains alive until Close is called. +type IMSProvider interface { + Start(context.Context, IMSRequest) (IMSSession, error) +} + +type IMSSession interface { + Evidence() IMSEvidence + EnableSMS(context.Context) (SMSEvidence, error) + Close(context.Context) error +} + +// SMSSender is an optional capability of a registered IMS session. +type SMSSender interface { + SendSMS(context.Context, SMSSubmitRequest) (SMSSubmitResult, error) +} + +// PhoneStore persists a number only after it was explicitly associated by IMS. +type PhoneStore interface { + SaveAssociatedNumber(context.Context, PhoneRecord) error +} + +type Dependencies struct { + SIM SIMIdentityReader + AKA AKAProvider + Radio RadioController + Proxy ProxyResolver + Tunnel TunnelProvider + IMS IMSProvider + Phones PhoneStore +} + +type Options struct { + DeviceID string + AllowMissingResponderAUTH bool + AllowIMSWithoutSMS bool + CleanupTimeout time.Duration +} + +func (options Options) validate() error { + if strings.TrimSpace(options.DeviceID) == "" { + return errors.New("vowifi: device ID is required") + } + if options.CleanupTimeout < 0 { + return errors.New("vowifi: cleanup timeout must not be negative") + } + return nil +} + +func (deps Dependencies) validate() error { + switch { + case deps.SIM == nil: + return errors.New("vowifi: SIM identity reader is required") + case deps.AKA == nil: + return errors.New("vowifi: AKA provider is required") + case deps.Radio == nil: + return errors.New("vowifi: radio controller is required") + case deps.Proxy == nil: + return errors.New("vowifi: proxy resolver is required") + case deps.Tunnel == nil: + return errors.New("vowifi: tunnel provider is required") + case deps.IMS == nil: + return errors.New("vowifi: IMS provider is required") + case deps.Phones == nil: + return errors.New("vowifi: phone store is required") + default: + return nil + } +} + +type StageError struct { + Stage Phase + Err error +} + +func (err *StageError) Error() string { + return fmt.Sprintf("vowifi %s: %v", err.Stage, err.Err) +} + +func (err *StageError) Unwrap() error { + return err.Err +} + +func isNDigits(value string, minimum int, maximum int) bool { + value = strings.TrimSpace(value) + if len(value) < minimum || len(value) > maximum { + return false + } + for _, character := range value { + if character < '0' || character > '9' { + return false + } + } + return true +} diff --git a/web/embed.go b/web/embed.go new file mode 100644 index 0000000..3eb4eaa --- /dev/null +++ b/web/embed.go @@ -0,0 +1,20 @@ +package web + +import ( + "embed" + "io/fs" +) + +//go:embed dist +var assets embed.FS + +// Dist is rooted at the generated Vite distribution directory. +var Dist fs.FS = mustSub(assets, "dist") + +func mustSub(source fs.FS, dir string) fs.FS { + sub, err := fs.Sub(source, dir) + if err != nil { + panic(err) + } + return sub +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..cffd498 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + + vocat · EC20 出厂专业检测工具 + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..fa4f7bb --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3028 @@ +{ + "name": "vocat-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vocat-web", + "version": "0.1.0", + "dependencies": { + "@fluentui/react-icons": "^2.0.334", + "@fontsource-variable/geist": "^5.2.6", + "@fontsource-variable/geist-mono": "^5.2.6", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/space-grotesk": "^5.3.0", + "@fontsource/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/space-grotesk": "^5.3.0", + "echarts": "^6.1.0", + "lucide-react": "^0.468.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^4.7.0", + "autoprefixer": "^10.5.4", + "playwright-core": "^1.62.1", + "postcss": "^8.5.26", + "tailwindcss": "^3.4.19", + "typescript": "~5.8.3", + "vite": "^7.1.7" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.2.tgz", + "integrity": "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@fluentui/react-icons": { + "version": "2.0.334", + "resolved": "https://registry.npmjs.org/@fluentui/react-icons/-/react-icons-2.0.334.tgz", + "integrity": "sha512-6A2boRTWkn7qOSX6kgLPVqCMZ9WQg6babIx14LKrwpsRGPOYx7QaqgGs+XE671wiJqNQX0JjuIGhvRRGL5zD+A==", + "license": "MIT", + "dependencies": { + "@griffel/react": "^1.6.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@fontsource-variable/geist": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz", + "integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/geist-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/geist-mono/-/geist-mono-5.3.0.tgz", + "integrity": "sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource-variable/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource-variable/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-2IxmvfB08i9vnGB3Ym/AXvhRE+8XOjWMXIyDum03c+tPwH0FUoMNQfGpU8NXPxjbws0Vvss3AH0Zqt4oJBBAdw==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/inter": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz", + "integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.3.0.tgz", + "integrity": "sha512-fqDfB5I9f1p1TV486aUgB9t8zP84P0O1FtQR5Ol9vjwPy+S+EIGlVYm1cvj2W5shcZMTg2nZFdVMoH5wFu8a1A==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/space-grotesk": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/space-grotesk/-/space-grotesk-5.3.0.tgz", + "integrity": "sha512-ksnGizDPXIDuvqcTYTSrmZ+evx9sDlS8rp7+42BQ7wU+spt3twEoXfbJz672C+5CLg6VeUQwRy5RXshWb67LcQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@griffel/core": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/@griffel/core/-/core-1.21.3.tgz", + "integrity": "sha512-FMnlwhtmCRWvXEg2j/6W90wzvW+PFqdrsWFslfmxwS6l9X73gO0dnndKphht9ZOsJODvmLdqlnU1Lh8igg2mKw==", + "license": "MIT", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@griffel/style-types": "^1.4.2", + "csstype": "^3.2.3", + "rtl-css-js": "^1.16.1", + "stylis": "^4.4.0", + "tslib": "^2.1.0" + } + }, + "node_modules/@griffel/react": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/@griffel/react/-/react-1.7.7.tgz", + "integrity": "sha512-XNBfZTgyOJOn1Buk70q4DgtwsQt71yH6R34lleImLVhvcIANP1yim86qLBnQSP03eQFXV5wgSmUVPxRUpdvYhg==", + "license": "MIT", + "dependencies": { + "@griffel/core": "^1.21.3", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "react": ">=16.14.0 <20.0.0" + } + }, + "node_modules/@griffel/style-types": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@griffel/style-types/-/style-types-1.4.2.tgz", + "integrity": "sha512-MsSghfpyxR2MpTrYdcCozISsSLkmFjNw94wNPi4bDBRLW8W43718W/ZjmUdVkoM0KXMtJPYuEkx8Mzibqb03qA==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.3" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.3.tgz", + "integrity": "sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.3.tgz", + "integrity": "sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.3.tgz", + "integrity": "sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.3.tgz", + "integrity": "sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.3.tgz", + "integrity": "sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.3.tgz", + "integrity": "sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.3.tgz", + "integrity": "sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.3.tgz", + "integrity": "sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.3.tgz", + "integrity": "sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.3.tgz", + "integrity": "sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.3.tgz", + "integrity": "sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.3.tgz", + "integrity": "sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.3.tgz", + "integrity": "sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.3.tgz", + "integrity": "sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.3.tgz", + "integrity": "sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.3.tgz", + "integrity": "sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.3.tgz", + "integrity": "sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.3.tgz", + "integrity": "sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.3.tgz", + "integrity": "sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.3.tgz", + "integrity": "sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.3.tgz", + "integrity": "sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.3.tgz", + "integrity": "sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.3.tgz", + "integrity": "sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.3.tgz", + "integrity": "sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.3.tgz", + "integrity": "sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.4.tgz", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.7.tgz", + "integrity": "sha512-APw5YuIQAg6L9w4sHDI6j26DGFJI6RpYOhnkMPdC9lWbkKvsyPHzDsve1yd73lk21yz7Y09Kci8B2Pp9FonzWA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.398", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.398.tgz", + "integrity": "sha512-AsvhAxopJGh6museTDMIjn6JpDYOfgu4RLlygomt87MUwBUqTfd/1EiPtx10/LZE8xpTvkP2E9Gafq7lkLtodQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.468.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.468.0.tgz", + "integrity": "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.62.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.3.tgz", + "integrity": "sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.3", + "@rollup/rollup-android-arm64": "4.62.3", + "@rollup/rollup-darwin-arm64": "4.62.3", + "@rollup/rollup-darwin-x64": "4.62.3", + "@rollup/rollup-freebsd-arm64": "4.62.3", + "@rollup/rollup-freebsd-x64": "4.62.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.3", + "@rollup/rollup-linux-arm-musleabihf": "4.62.3", + "@rollup/rollup-linux-arm64-gnu": "4.62.3", + "@rollup/rollup-linux-arm64-musl": "4.62.3", + "@rollup/rollup-linux-loong64-gnu": "4.62.3", + "@rollup/rollup-linux-loong64-musl": "4.62.3", + "@rollup/rollup-linux-ppc64-gnu": "4.62.3", + "@rollup/rollup-linux-ppc64-musl": "4.62.3", + "@rollup/rollup-linux-riscv64-gnu": "4.62.3", + "@rollup/rollup-linux-riscv64-musl": "4.62.3", + "@rollup/rollup-linux-s390x-gnu": "4.62.3", + "@rollup/rollup-linux-x64-gnu": "4.62.3", + "@rollup/rollup-linux-x64-musl": "4.62.3", + "@rollup/rollup-openbsd-x64": "4.62.3", + "@rollup/rollup-openharmony-arm64": "4.62.3", + "@rollup/rollup-win32-arm64-msvc": "4.62.3", + "@rollup/rollup-win32-ia32-msvc": "4.62.3", + "@rollup/rollup-win32-x64-gnu": "4.62.3", + "@rollup/rollup-win32-x64-msvc": "4.62.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/rtl-css-js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.16.1.tgz", + "integrity": "sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..f0f22fc --- /dev/null +++ b/web/package.json @@ -0,0 +1,38 @@ +{ + "name": "vocat-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build", + "preview": "vite preview --host 127.0.0.1" + }, + "dependencies": { + "@fluentui/react-icons": "^2.0.334", + "@fontsource-variable/geist": "^5.2.6", + "@fontsource-variable/geist-mono": "^5.2.6", + "@fontsource-variable/inter": "^5.3.0", + "@fontsource-variable/jetbrains-mono": "^5.3.0", + "@fontsource-variable/space-grotesk": "^5.3.0", + "@fontsource/inter": "^5.3.0", + "@fontsource/jetbrains-mono": "^5.3.0", + "@fontsource/space-grotesk": "^5.3.0", + "echarts": "^6.1.0", + "lucide-react": "^0.468.0", + "react": "^19.1.1", + "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2" + }, + "devDependencies": { + "@types/react": "^19.1.10", + "@types/react-dom": "^19.1.7", + "@vitejs/plugin-react": "^4.7.0", + "autoprefixer": "^10.5.4", + "playwright-core": "^1.62.1", + "postcss": "^8.5.26", + "tailwindcss": "^3.4.19", + "typescript": "~5.8.3", + "vite": "^7.1.7" + } +} diff --git a/web/postcss.config.js b/web/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/web/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/web/public/ec20.png b/web/public/ec20.png new file mode 100644 index 0000000000000000000000000000000000000000..8d9039eff721d0268dc3d9aa1b299fb019aa6157 GIT binary patch literal 208937 zcmZU41yqz<7xoM(pdcV1($bBnbSX;9&_fO=AQF-SLk~z^xP z^)vswOcd*6a5a@L$V(2(|NFY`1IofN-Av4vGcB!T|NB|Y0g##onSMnDwdx~(Z?7AN ze)lW-zv_PHL4TR-1iA64829Uc7bQQDmOu?uGEyw%wWY6nsg>_P3x&kcj4xLrt3~v` z1&5*uLJo(HH6Hi9`&j>yZIk53i(hgykE_kzEf{2!Y6A7Qr1b{jsH`?uKB zNidH+b!#I2Uq$!B9G`0V>Ui8^&qxjVVknP5Q zTO#@cP|V(KhYiDe+W$HX4-+Mn46Qe-Mrh;x_f{3U7!_<=d@`)~k3C3W6AkmBy+8m) z6?o`n(|(5j!ito-Sew4wZE1-Tbg!JqoVELNGUVVNpOC<=w`&?wjm(w466CvBc;PV9 zARW?Gr$PuLtTr9i5@0EJmj&TL!2f|;p74e zxh;tKoqejp3dS}X`E9h|I^e7IL98-BvCulW=#ToE{6^a{FWSv%>1Q9H71s>N)GS`` zh97K=%{fo-QNm3BTZ3TZ`Nig9MnQOJb>tNksPo;=r}^NZ{?-?N6=eX_HJ`uu#}rJW zG3R*we#1S>$`?v|Bypm9lyVUCjySzYBk2@%dRnFn({g+_PiRMO62N!a`htn&j;{G* zk3bym3M&JfF21n|Fj}}gvlwr)?=9u*qMU`Buac&JkONJD=1(t5y7nUpofFpM16M&! z(^@^ZX0VaAf;3&Yj>{dkdMoT=!m_^_HZuOU5!5tBO;K#6*nAC?&>M@{a^n`M$QkdC z4QlSG&@3IAM#pG``A9muJ;gRbMI5Q9QiJ2*3Y8)4o%)Mb_Bu|c7m(tnT?ERZe>^90 zG|kVlr2B7cO2@|75$(fDVs+lVPrOLYWd(okn})aR-;3h_U+;+B0Q5GcT;%6rv4U2` zto4Jcs#Kii=i?<Au$UKhqEA{jK=7Pn0-{$@jgKdni?y2zD_a8N7_g}lll^x{47)^p@sMlU> zESq(6YRYX)jFuJfT)t0JZR%yl(_&KG0$(rp;2IL(V}T_;z_k_Tl+YgP9nr8sIVDsH z!-6=ruY_oyo`$=V@oD6$)&ByPeeMS#oFvC#Ey$P1+I`fvnVbHq#i&or%Uoh=inj3O zK?$r_&KIeVH{v)`URDkNaijANm>uisHs;VaG=l{pWj5yGXBi&D6Gp1n)~pct z?G39Zh>sK{UQeYGUoTJOM@$~`VC&qexaeBA6}_eNBff`Q1}&M4yH2MlPy1U?32GR- z^p~m^+h*;@diuVX<6OgKe_x591}M6NQwWSe3q4t|+uZ=a;_=w*sJ1+(a@z88vgT_N zb0eC^%47*RC4<(uhKlc~$WlFfa@Tt6u&S!;R{r*p{CJoHzk_yz;2ruNKpuceZ^P}) z`MCyMW7`rSx83OI$FRa8XoBArYSj}@{$Sx#*IKQpTT}Uokit;<#iZKkzEJ(eU}DQ7 zd3pykT92(K4x@S&fku95>{U&!*Ia+QIi6CCEm(S09XC^o6R9BP{m-4MVoBx|fda9N zfjq!4L5;C=iJr-*%5SU+g7l(FMuv31wdG;7>BAH0&hFi10cG1y#TuI!!laFT%&)de zGTO!>uJt`VcO%=`PbUgs#YB8?0chC~urSK;KPppT=gTTv?N`LQBs!wUw?H{xYB}du zp@)@@BP4>Q1)wqais3FuRY_K%4pyp=&EEz|%2H&=d?NV9&Fs|`B{pbFZ`L_}^lb%h zEP1N)29&K>H>-;rVUnk(%@98$!qrXtbf5?{?4rT`I(d`}{Q7qSWkOJmNhQXyi-(K^ zc^QofVKMyi5KdUz41i1IF}8ly^eQY&%*(x-qh})WF5+!lQ{~G~M;*Q_xi9EI8uA-5 znkVXPn>Ne_4`-fuSpjuAZqI^%hTAulk?m5KI|hJJy*Ik{8z1W=7xo4M!nLQ;jfsYT zW7H(Q0Tbm`Yl|g|anloUQzxEnwoA891Fau;p_GB_FhXBJ;=(AV<5>lo0e}85GM<_a zf#${=w>Qp>?HZ~Y1(BUG-0YHPgLSXK;M)=@USh+VAAT7((-zFF@um+a(~Aq9oBYSX zb?;)L+q=E{8xIm}i9R_y2QZVe*vh{eZ$m|h0|^1u0-}*|F7puf?gGw~j)h zWh{rB<$qdqOg}0vLqN7p+s%nS3c4IJ%8Rn8<&{^4eJ%*2ja~q~zF@wn$2oGzFb5lE zX^lM;`nq?L5k5L6qGPbWpD0?ll4cGmKI9V>ZuUH(aM%N8gdm~S0-qs1UEj;L-U9BolWdXxI1zcOGkJ`9vS?1zzPdZZyKD~BH)z?4 zKA+`Y%A_Ul-;A~p6{XG&Dxd`=r+H4s=rD|zKX6M?$~t!%OO-dB4w8D*BnI1eSXkfhUuYJyda313opoF8ObU2;p(>OcK>xVthj0J)2n?5HPt?pn!iJJ zH@=p~TGkA@VL>MxQClDj7`=8{{YlWlU`#gPb6)K9?tBFtENv((C1eHHo|8TasvLQ5 z7x{#4o(o7#tXor_NBjY(M`Z-{2Ix+;_)NukICmEn1Wp&bM}3%jR|Ch<%~W+D_*2ZB zVV4XU3-SQs02=GbO1qkU^a@#@nau(6x}b-V_*TbbwiL+0jUR!h)B+|l8Q#|y$6YsF zT{YNX1KS2|p2OLt&s04%;y*mL8_(wrCgNAWv&3DIkAQ0fZd_s)9R)up5^p%6 zMXTG7Ig-)KA7>&)8R0Gbp)97$~JLM#@b)j9em(q-%^*Jumo|o^O^@iEqQ5 zt~&6g?oZiOJHt(jOIJNg0bHgmMeNa>*jn9y1oulu^^k2`!!+mBScp#!=%09PiY_qH z!;h`daj>8QX#~zV#kn_2m&pePOYgH30W_()m3vzQJuikgXV&!toNxC*9iPnLDTBmQm zJapR4pmVx8tzAp^28%{+Jk7ZDOm*rLePX)W`|!y-v#m-LeVcV#aht($h(ksyNk!Ng z8$gK;za*v;T1sZPufO7O7eU9O)S~fs;@pBxoEJ`bssF(O@^8@T%}jx{?^kpY+|<-W z8v>AdKEi&Tfn*T{k+L2;>uegwbOD)Q!B@#-_iHu4w+{tYomo*E+ty|GNoFLgSqj)q zHa^-MG8PQ+fvD@H_G3AKAfe6x_w&Zh_zf=-N!p?JeG%rXy<7mR_t=HcN(A4hjeB>{ zz3T^U>AiHW-btqPfzMB$4N^7hI+nmsN$zKdvc388V0Yqe&|+KT$BWOnx&-%Jb{^mq zMoGzNr^M2b*E>cPj?IZgEab+5xOOQ^EQ~63{z%8C4V_V5bB}&)9KpKv!%gLXorbT_ zr?ILW&BTZ+k0gww=;LN(sT-xK8#zjiY5=@HDC-`-X1I6gh=9Wc1;JIR40;J;o;Eu0 zmN_?&i1A;1WPPy@(&tx3Xe5Xm^HtOI9dmawB*owNCX-9v>&&&2pCxH_G}~%gTCIGM zbJ;%DBGry?PCM(zZoWUYvzLx_)_gj%+jej4D%@RBpX$-os=7P{|0vsLOBJB_SKULJ zAiDDrkt5g-rDkpQ^izEMmtVVnkwh~&xzPUDm%drJawWn7!*%NBygfwyUUVkd{-HAO zH&*{mIyxqv2$eAZsrD%6(7Ea>i)JmXd>r-(b-E8-jZxFEcpXAWUYsP2Ep56WJ(uaB z=L)nev=<2U+L%yHSmE@-@93wW;VMQrX1-RiElrFdDF2=8xfz!&oZCN*l{P~Pqu_sdv7_f?cwc8Z~B>z|Zk_j({=Oi4~H{PIyv1?~fTE}+d#hAms$V%&z5X8;l)4Y~xu7E5r zdIC{hxFQ(z#Sj)h1gOYqOFxKvlc3lKzQq8CGrWx0_)i7?R z7xQ1enki`j>hA z$zZPXHt{_8A|ZD>#qG2JtQNk5uB-u@HeC769}?i z3*s1d1!%}^i+(9c<#U8Qg$rjVI`-BAXB+= zF=Nj1?ThY-0ltE;f;&k7u1-Lls{P^zKT>A7z_B$$kC|iKXQfX(p`HtS{F`J!w60bQ zUU+x8-b$k@w$*EOc~_MbO7QHnC$>^cO*LOM3Tuc=r1Yl5N^u z?dQ0%%lBcse~km5yq{LVg=i3wlw$mOTU@r zl;JNom+BU=>X%P0xp%{tB$#;NsRR==9(d5$c+qh|Q^Ihn(!$Mlb!~?$uzj8Wt9$=$ zOh>@}s2<9^@?@R@yHmfixOkl+8m{$4dgGwPdk*nVgzN1lZwKWy@H1Y(DF92$y6ky= z>Kj~}=SJyA(iO5vnF^wuQ&qF8L{?9Uvz6tk`^7Rx@xbSh7hPLut#f4}g_ zpk*=pj5Y`ITx2*O&x1T)XNLoU_U(M)@&2Djg&Z$7hf%vaWx@hd6qU$?4e{ zXV9Jvu^CSEB|gSx0o*ZX`H9+lv(lw$OCq+Q&qj- z2X>$!k%<1%fAWuHeVJA-?dw_;!km*!p-q?He^2oN-5|F) zrG19m<4{Mu(I%2J@2%tf=g)CT9?a(nu8(?{*7&G!hoCl=z3|H^9jo&}E#kI_?OI3G z_^6x#W;%0P)_O(Ngt*hpH`JDz9V6^2aHpFyGsVftm6D|k=I#25%v8Sl?n@jxHLf}I z&-RrK%3xiyNPk|mR)%iJ?Hjtr>d$f59N?EC+Pjjt7&m48iQ4g{lt74g6J_J@p)>44 z7E2oI7mfXLH>U(kOQ|Udp+z}DJU{CBG|(lYpvGuBcl^1=GEsJG@jVjx$J1WBlKY99 z9aJ^iLE*vjxBK>-t~U$uj#{68NH+0{2#n-b`IG{vnRVPIKqctaUa&0BO}~vm-NO~kA*kd$41$L z0wXR5>O4Q)Ge9hBiR!%g5!L$n-PQ3JJ}ADinV;?{+eLYWA+)Wxv`*Stclhe) zP1&@fj4D>TivhYMJ|Il#Yn}<0SJ6OJq~(y&(r^4c1(#iZJsaOyA}kbq?@>~+RF+pN zSyokx1I8rtTj-iyZq-0^j;l4kIUWO>L!d-QUJC_k7H9}7jHhDUS*haPJ7kC<=ZbI% z)Rm(r<)vA$DjDPF&fm)PjnaKm2TSJGhzqCJvHX(UfakDM$@h>c&#GzLjOqhr=GcDT zlYy7CHzP$T+hjjXU_lXg15=DEAf%!yKuR~+Z*AIl4Og7 z*x28S#5|VFKg0>v_W7hQwuJ~zCdZd9#CHjTg6OZ8G`l@;Z&AaVOW8#Ik-c>nHl{gCl#el2Hh)K2U*eRbK!80gmIH>P#;+V0T3EpHqH zZ6#&!$9JJ@sbolXtWm1btiUH9^(}wyVY&B-zHh-Q1Uz4o&>YMt*bFq4y3A$jWhyUK zr?wjk{QN%tbYE|tcPq_JartOPxw18FsDMHSM_~V@sK(n;Jb)Xn3W)pyJ8A7u1E^yb z9Ck`L2D+@B+P$ypFYR^p>xsh^^iAbX&uB=NY*2fR&$V(_qWf~|WvIk$l4*jIt)AVX z4R&x`wXtJWt#{Mi2MD*%$CKFyjVaVa`BZ?F8eu?!2kxl9G$Qw0T58WX%9QoNn)yK5 zShsr$O2R(3v}ymZm2;h@uqnw1$&;kB88_6gG|$aakeI~qq0jeM)_G`N%!{HsX`hQw zM~==F=m<516c>?eRJohS1JUjQ|RBB%KJQYW;( z2e-?TUzDknHOGs0Np!9f>oUWb%tJ+NU&!mRdvsMSRK8zICUZ&gI`v|D5QKL#)mguE zVB8Vu<7j1b+E~+bdE)*^#<}C0Wt%dtjR+wSsMC&>yZ5VhQgE5s$a_Wm5TSt_U5jQ` zZq}j?L3+rxyjd-!3?pqc- zHZgCuxY_Zu*KdrPH%~M|SDVyiQ?_+p(2V(yLk!Oh35dMkO|vV1xUa_j-OZ<0mQ;Q< zTB1k7B8b+_eha>-y0WJ>uhcv;JY_p{EDBsP&-?>b%qwD=4T@|c$bKMUm3fS#RaK^v@Jf~KK@gdms3TB72~n{4Nga!`L9xBZyX=Q18%}bS&{)|l z>P9^M{$fwt)Vne|&XyhL@=#9Af`mjW(3fjyA#vfr&b5cn#g9CPRqeW;Wp*APx2_gQ z;;}`1zX~~nTDW060JAY8*AS5Br_-pa{{)z|S>7bYHvKG`1HLK;gyG=L{4jtVejRrSI{nEfZfoq=Yq)fl zkwMBZVV!Yygp#6hIS_?uNy{i0HOId?ktGq~fpO1rkL7&s6-C8KtaKUS*T?i67lC&4 z4oYcT0lMcZ0|xp=x zCBVk$61IoitWV++>~Qs(T(gIP;Bp`G=rk##?&|4>`Q6kG8R7J$p6w4QvhBTml{Cq{(DyIe@FV4fz- z=~UP!;n`pBdF#B?=;WgTi?0rjvR;xPnS^n?MfC93pDAWZ9$Fgnm&?v79yD()_5@tq zPBM4$jB-|+Oii}DyIslNoge82rU2qK`&Oj(r|;Z4yGrZ!Wj+#FT0I-^a1EApoqIle zv$JhFcYVrjf_LhtkVvP>ooE=kH%{VN}X)M2QT7&3AE>XQ9mQO7b-@lcF?bRlu^KhLJrvZz06Mq5S__&Zp>KQ>$VW4G(@*#A@R!g%{1hb(Dz4?c@3;0d zXED9rSCKvs$@R+0bno&d_dj%zbbI}?qjOWdz472*O%Xe@%Tys>DWix=!xgM5ks z%lmt6YtVWc%QHXjJ7w3H-|3d8^maDGB?;t~UN2yR7q8U8;P~pU)&g8b`gTWnaS+r} z04;`=zzQ~-E7V2wWl{zhB`+pG)GJT`ML)Hk-LPoCVOXr|$%5VsxWXi@bwxK}4>z6+ zE{94gR8yoKfNa?DU0EFDE(_lLqBh~!Q+_{EwK;#wTl^4gL1h=`e>lZHrxDZV%kix{ z$@zP-X46)c^8N(?ztAxEJjceHrnJR@p!j(<`!FL-1soo407=@dH|j>;EDgXuxQ zT{l%^pg_{V+}9jA+=*RpC)===gQ_I)^DhF;dklLlEask!ukPGE18789M^Zm4WcgSQ zKj>zPPAQ3`^LVK<+-#)?{+ZBzwnNrt2D6=tJE2xx&?ou0!~uRf`H zlof4C#d}yY3)j+eXKp*;`!LZqA)U~L`LmN+)L-6zkP>!G*PVSbXB6Ehsl>GTwAZ=! zJ%5RgC00pCyh3LD1)~uI5YOU?PEO`}p<4$gheh|&H3~r%$t6x(!@y-2hci?2Azrg| zdziQR{>~lW8mgroHqV66Z?xIRqsGQ{FCi_9Gwv~!q$aqA)29PJ4qNf5?d}cW7%tq2 z8tC9d;;7?kL%e=PKl!vakR<4AwO5jqBE*O#Up}+u4BYAj$Jl-AYA@$LJSBTiFmq8K znJUhhr4ksHl}hV0jv$4KM;v7IM#U)sU_nkvox2+P~k zn;+?TJm`)xi9tOGX4N+k?iZvKTw%#|+vF4ACH2keYMxuZ55LF^5q%D<;+Q&w#KaE$ zKyz@tg5Z*nz`KOt$AKXb+~(xGS?UxJA4~a6C%%P)cP|^Ujt(E+BAaK=oGgJv%7JHM z9k>uN^E78hHgi3QZi|g_x>t1sv*T{#57PSqRlMe1s_E*Lf{AirjR3 z-}l7`ZYMLF<>8qL2ki&WU4hmw(6Af!!v|kj6f%dOlBhK>;Hce$U-kp{N~`;Nlpy0# zQckAZt=xG0v`vj9W6%4BvidyMO89L`RE@@#c#pPxxzme5UXJ~X4PxudvG1<@30j?2 z4ncJqf7;t##wEg#-6s9{hHo&cOTv)-)y{x*T5+I8fy5@kyW#5T3HD9oeFO`VBSfX4 zGlzig{ohBjabVa`Omx5J~8haABpgxEr^0t-v$G42h5+mGk;tI;s{a zR4Tw7Za+bctZQ~AdJ`=U08*6KJ3!*RSGjqDHC4){+0Hd0%Pzj4(-W zB(TA|4s#;FH{XO*SXUMRwU&b;`g^#F%O1gPHvlYU#LsSy;xu1&#@I;l zW)7r3o%*(^$7U>Z8t_-Ok01BBPRx?@VMBiqc!t*J`@U28*-+lffZF`mH$-&xLOMh| zxR@RuCp7q&5~53_`;2E&CiDldaZB%W(3hy%2UxF(zyF-7;#r!U^y;jdr8)bZj&naR zLU3?2fwwSSY!a|x}6ACz`et*PG!UK84D0|SjbcgKzZ@bv1U-+8${LX%tdmBZ72HI0< zHf@()rpJpt8uI6JkmtEeBPDSZOLW{<_mO%>-k{G|w5v6D$N3;`>bqg@F~w%7!I!j) zGGz5Kge9)UV?};Z)8dLr4sP?~<(!+roNi@tsJ;l_%Y}ocThh}R54*ec0-4_$Tup4k z-^zYYTLTC{`wT=|-&p8Lc1h-@J9h8KVRvnO&ph)htoGSBm$(yv1$HZoPvs5@oa9^! zCcc%MjPv3yM+2mozR=(S%chqE10_`7R7}MQUB-4~xCc^tWm zUS9}SX%e)mt670H$(M(2o9*0bl$7TMq#l8vd{t`Of`ChsZM z5VG3NuinnUmxn~*YzNUNkykUUrIS2>QRABsh60&B1)OYIEv+=HCQL20TMo&3X<)x2 zzT7$efwj~4QBazmJ7gFzou6X+D7~k}?yPY}SV2C#`!ZXvC+?b)D+u%Q{KfF!;+uJlo5q`xP(yvrS;$zy$AicoF+FMqgu z^lTFWXQW)SlDJ1)@=au4HB~6DU`q|V3^RyuV?D|SGz2R2Wo-O%dYzA`qfroL95raV z%CDsr(Z3HEk`o4MI##iiC#h4mJA%3uCf-f(6&uXEu|$Y%6xLt#gWG~hnR{#-%Y9ag zado>=Vr0K9MeKfrOBvxZN}M6DS$(H;6*SH|C0FYni;9RU6|oV~M8(o5a%?rDLoAximrDdYBT zgPDZF>V_`<+eZ9VDYcls^~cDy;#j#3USqdVt1O>%p|Z6-2x%|$Y~RfJ?MqX(Bw92K zpzB^JW(_YtPujj}MD;9R>ud7}D)Q5iFJqx=Pzl0={;SFg0MPCwHe*iySfStI%I3oj4l zJC?gxhG|mXsz9*jWeFJp6vmRy=@qjK;k1_R_S$K|=8p5y7Hm&ng16{Jt(< z_CMgT`T;G^j(*;-<=fk|nKN%;wd!!{u;H#a%|jt4rm@ zF^gNVo#xA(Jt^sQPPZ3EKXmvNHyu*}gvhK|XiIO~^VnYIo~~aOgQdcFGUiX%U0;g# zk+&_G;xFE&x?8Dyk;nr&N;;s9I>@KjKWTuM2x9o3qi!xul{OXpeV^J!EEJck6Vv{Z zdKm*(hFf`D?^Hf^A-kT6Y^jFk-@&vWMA;VaanI5rldMlaxmpu0yaL4Hm!pTRa(~ST z8_a9P?UxM6rU@Pw$j)6a5G=S6reNi4S=jWOojNQjjSH1e&_@zm^~6b%IQIWQvvpea zg(#Rk?dlAe4bc7r&R5i95S6xTf{}CHQOdo%ej3wg=+_YaS8Gf}l93}d;i$jrl#F3` zKn<5c&RF)~Zn25)$ieTEM{fXmskxE+K;9}G`_+<&l)8YS#gYgMgM^W2m$&qx0(OI8 z?buE~qvhj<;JgFK&W&(X$PT)D_LsWL_^+g#N65s_(&6ejzcOg&j#I3Xh*Tpc&96J} zyBUlA6u5raAVJnvFDFPZ6HdROA2WY2kGyPMDS5`59K z-C%gee@w%q?FK8oAR=heo z^5Amxxse1c&WWBB#`Nyo!GjdLDC`xr>&0zj##z(Qaq8>P;~EI>9=|S=Xs#7e7C0K{s@L zjU_Fs_*T-q0cw21tH{vUF)d-RA^wUGc=SE-4(|L@{!9Q;-?qU=!;NhHaO2)6|GCC@ zKPQx1#j@0l)Oo_$uiZX=ui0lO+u=dQP4LHZWTNvd3lfUDYG;vECm&s*AQ7s~-eOw< zXLmSM!uiwItDjVIJFmqJQY>bjEcDVK8Wxf(^-ln~t}XlbzAhlO#d)*=ii|zgqKB?j zI8kjq1=~gjc6689b;fu0>0Oq=A*-5FIp=$X^)+$_(S zA|@CtN8{P`e@0qY6o=h-Dkb3;o3w6KD0 z#9wZ=(cA~`77*$dQra74#!CfvQOS<;=pw_3spp)PT^UQE(;<`ng=(!q)LdiRxI)Ei zD$zxgJ*yu*ZXo#Um~J;GSa_t(VcFR+e}8bbs?-yy><MUo<7)z^6C0Qy+~X3-5lj zvXH;imTP}6Z-_9`0X10swsk7!m|Cw5X}&+__0$3{aOKFFkPVp(Z0nI;Hz`-mz z2hJ+OFT#Dz^1E~2$lxp+!6qP~_TG&bBF3P-dfm%w8I~~PYdWf5{Rvxy!V$7lXVhQi zk{f~Ubi_8GN|u6Y?S^QKZ!b)&=>^kYo~}LcIK*c`NDl-8yr8J&)`88X=?%6*gw`oUf_6e|(n&?iJY=LEX9=7KX1jFJzi@WM(#f1Co z7n%CJq9gCwT?VX zUlN6KRTAO^y}lJOo147__Xj{k70alQ&ld7ST9r*tnHYVc1^y%-UyUEedbIoE(>*+B zaet&Xq6ir3W4KYHg(7{QoUbZ6bmb)m%=~p^NCb=KcK$JULl0n2K401ylc+{F@Kww1 zId8<)a9-wxHOsyVkenfJ@W3hTHKMDS+<24n)5VU425#MVcgm)ix99TyEwr`a$;rpv zFk!^Ckya0|OhOM(ox>p)K}#xAV0MBTJ`q_-Lm1V4((PpEr>=;xJ1%PPL#CW+5E5X4 zWhnGJ@7JKaRBo921pT3Ui6t*KZ8DN^moG4#+qR~6{yXp0JH$DuxI+Wl)I4bJe;AXS zWvgNlYft>?t&d%y${@|oQi=TOE1V`Uq8OikBuNBF|zfrZPgbHUjFzAIOHL?~aQ0MCx9P~DxWGJaushOuF3 zwdzp?lKhZN=z5VyN!+$Z*r+p@o zD1<`A5U@8+=W~cJM5>vo66PC3k4?s+@50jG0O9JNV(o_gZ)UG&N&KA}Ad;t}F=724 z%Th;PmrZpf^H#lIn3d}_AFBDV1oc(p2HI%sO#td8hi1J^)`r*)<;~!+-8a03C!f)c z8~#XQB;lJcXQ%9FPr;LGjIQ)iKQOb>3_7Kz%^W=Tr-;)y-UHKBIzV!00{_|U-Au_T zp1w3O%ExgBJaw0rPw|j%f!OHTSXQ2KJf&{OQv2%|Kt9iDd%&5YY>M^L#_AHJ>ppcE zojG+P=x~)y0v`Zml;ELM5q<75pg2m2HQbMQs7%e-6j6nHxIE-TeM#^6K@l=%y^Hwl z+UasV{gp&_-yQ(IU^GpVcU-BJ=m1eitSzNEEmn#xU4_1Ejyhq<+}LU8!Btxs5m+VC z@w~I1!9AOp9EzQ4bpVCj?E9u`@>O6j?}z3Q-D6mF4}Rz2Tk=-`yTQ4IUyVZ7CZqTjz5 zv(EjRJr=?(+oD`Pw*HcS;B$B)s`A=71MIwD%Yxe5VwR@=d)``fuDdmB z+sp;Yr8uXg9EVHU)1@O&qbrplR@63w)g0{6=+Rw%(ARL`XV=5?e%sy7+^dnMKjAfe zMzu^@HNrrpQ?2_*_MjA64k*r$Y>Z#^34!s`8RE;A71(+Qmk0|oO?tu} z=gat3UC=^IR6c~y@!pkbQzTJ(A_kKOMDgsA4F-(BPx~eO-FT3WA6UYBL>Co-Wwd;j zJnrqv-EKuR)m{vrNa<2ln@ajjSByS)pGqDH=qPXuAqYK4r|o~u5duheuFxp=Tc=j0 zjx#W?NOZ;CRs!AmGoOH0!;wb<*9ozOsXzcn5)pNSD@*S(gLST{GbrWJNa|}nN zIX@y499^&~phdOTGQ=itcVMgAbnQV(jNV@-3~fOvr{LvEJjG}{1bIj)0ma9CY|L{NCgMziv;UdRIG=o!F7;ym9>aL=jHS`$mgPr z>BWSy7qC>_y5(75S+iV}HG`CyTFGUVv(jLdRB$SLSGIz3Kw+0=1aGiFJNKfQ1r39VIU3JTEcct{QfbK9XCD9FlOYv;&Xu8bF?$1^FKVe%CybDYzb;vgL-bnr9)lA$2YQf4G|2jwZCq z0s?uMENBwogNoGbUf%}9*Csn%J|Ibt;z$uy?sX=e8q&M(#zr(qY86K#Vu|d@7?_EE zOi&W%Rst=9;ubK}&BP+(g{fHzzh?;OmZB%rFgJSK39(03t$b&B7>&FA5{>t|*176h zuBubvr8!|djt3$B-=hK|B|0>0$=ybI<=Sfn7AaqSqgzOaXe)s)x4+M^BZKl#QgfUxGIz z!#nKSD0Fy4HD11e^>~E~cV0si=dz5?URIF8V#kK;kVs!vocBD(E(W7DOdq>yYCZbWqfUuX} zFi5@V&0|!&G;!4|eM)aNlPPuvf%)>DChbb(=OdkH){1Vl0Aa;6{i1xBn^A|7VB$r@ z?ad_$OIz^*GouiNxn&h){uQoo0;JqqaYx4#|HC}b23O&Q2Gx_%VfK_jFKP7MEZsXMcFWi8f91V}Iw~p@$bra?P6?hl{^` zB-XM4Yr5KL>}X>KCW2AQ(npg|tm6;Az=WNh+J2FmOE%Q9Z0ohph^IGh zt+@B)Vr_K-bq}*q&5Z?f6v!d;U>McZZoMp!W+%3!07!L2>s8Udx8OVMHU;Ct(UlZ9 z_1&}jg3sWd3;kpmM89F{)$XaovX4D&+8)M8a-TT8D;>tK_1{HfC4M-L4sr4Qfg=@r>tA z$IY@Qy{OEs*=pMo7Qv5tu3_>t0(sMs*ygBw^pw7gHkth~QgPP!$V8`h8BsSfu zck*Vj3Y}%~m!XVyPeiMb$74puNZ5jB>k?6RpGzo^x-7EocWd}dgAKT#EZB&5kme;- zo)jIClv1)wsP*W2lwMLCc|}ysCLmEPrZa+FPlC5|W7}aQJho~~NG6bn%5&_Sj2Ne3 zp;dT1KHmMoAsAuiXWJNuINsHmXhh4lvhj@-xvCfw+%XUzJ2S@S`oMWC=h19=hunet zUzz8z;;sJVDOSt%tHD(f)imh`sSoVp=-U0SF z*a8GcjAA0<-dj%iaWk+bsU!8p1L9YZmlRCurkr(Mh(eF@$Cs-fSisB~*QuJ+0lwDs z#EnY5(>QE7-_BAsex-!*@V%b^oTV0|o{tK3>f^0(p|4&d#0M3H!&qLOU@rT<1MhwpL zo!Pq=Zoin`Cbg@Uaoa%FnI+aaImsk|iGL)qyS_ zDSR3~?c|2|WEh}tVUC2JfY9bNDQ|3dm8=gKCtb8Y?Qdf;r(}zBp7tA9?e6sZP9uOe zen57oJ34oJoSS+bN7^4;Id^t3hF_i#MG_fce1;^YBZ_Pj7Ny>xEpA!~$SRLm(~mP^ z-)w(5Ice5gYse5KVIuD|B_oY&PIPf#UQrMX;dWCvx*z51kAPOk)nMO) zJaO!d#>Xf5RqlOUyqMNyjZGS11mFpaJw#6qE=#$+>wRk8uHHW7RXg|IdU5gIC1&yj zvbCvz4?(*4y!|C?Q>FOZ$b3Y5DZU)~y36Yw{=!rpd{sN|7XyTb_Ly2o0-6^$Q#@7; z2;5NIK2Ks0O^3*yGRrF&9W>5F9pnHC!$*Cta`$t5JpZya2b**DUjC?l&olD-jqccs zBKYeF*nIqfyYUFtKUe_s+lJ&=LEPaFA&>&uij4|V zT~W4$k=vUudnE*;wy*@0wAFB-Zu5TCJ|xC9=2A%b=O}dJY|E@cc=kl_c$8Spx%>D9 zr(I$ffQyzBTRUbl&4D#b(y0u_&0Z~SV80?3pFoSb9Jnk|EH>1J#5!PODd;}k!?k02 z%4@tsiOb?Z#c2>G7UCTvl+HOD7DvF;N#htEw?IjLAt4FBi@DtaxSwL%Xer7$VQ|)%rjC_V{88*3Nk2 zTLBfis9usy5LlE62*4gW>Zm622S9E_ zl2en|yGZf6T^JT+@kqWaC8-9-$Zv0mGw{)KLO3%w0{Lxe3k-#Gmfz%^DzS^dLO@m< zTOz>W6oJG9hXwdaUG4_Ds2v z>a}XR7?j3v<{-ZAr?c^Rp+Bx$DXh4+Mrl@5r<8vJ)AQ=`_I5rA9mj%e#M-56xxj1q z?}-$NYpyp3(G#0RtARu#cm^r)h_dP4P?lq^FNchg^rmaiTD$a>`)HOih2?@`rkU^| z2aI=!@(rPwjH0NPiWbWef=^#EE%`d47hA7nxZ1AUzRE_^wx?6wU{#w5{&*RpYt#D4 zXQMA!Y_XHs4{`8?=J_l+5eE5UAln69-8isnwQ1&%2uh_7m-QglEhXIM#20dHyvYje zcDC`3p9h9>J$6#0OC6|g9h!QLFXQm-^U#DZPo~=V!D1+1m}#UWoByMka_=6#YLz32 zVREUb8(w%4#0qibjBM<(eO*<>nxd%a!=JH@G?9*j57c z19CfS|0=LK<^a4=tpTugp{pwG4_4i|2NGl#q~*L6&Y?3o6=Pk=#Ral$r!|^w-_N+X zrSTM{Jg5i@plThu${|q;*v#H4;f8R}E0vA-5#P@5(hsD*#KPjX5S11Ix zd!*1&C67>IW(>gDApKfkmqg}u1pAEpC0%8!Qe@oyU?Z#VjEED|ZKUEXuaavNT*)%X{YecS6-2)dy8U4CcASup0B_4F!)AFxyZ>9BUtT zuq8fGBe-O|J@Qaalr&AsWc|j?ewrM1hodUeg`P%iH8$({*g(SaUVqbWV@|4Ud{ye0 z=KgU1|DozV!`XZvH|`|%sMVrs*REN6Q?&M|4%9A6Yt~H6sM)GbQH0j2FGZ=nVsBBS zcEkv^V{e{2zvK8H&-1+YE^^(2Nih+d5 z&`s)8A1|+X(!wox*BD_ph(G~00bg(u@QkBOO-F1qR(9lkQu-3)R#+4^1&YFc6TDP} z!PxDy?E@!Qm71LUT@9VS6diqC9EdRKJ@ocV%i>>Dwna)sQpfMf=*LxEi&yuWxHNgK z5V;KP#K|{r;2)i8m=M4B9T8&)VXN0%S#{Vu`R{h?R$r2k z(X=R|t&awYX8&|q)KlDDf*S{7CH&|C^w?upjz zYm7e}d~v^3KJ(O%RU6|=-awpDm4VwKcKRL4 zV1_nqbU_RB&@!>+$JG`;lWs^Am3n);b$8U!4QJp`w}a39lZ3j}E(ehF@4Lj3NP?e} z8mX-ePt0HzCS$0Ba0Fdr{(V0sqQhGBA=J`2mSl(fH-i(+RYV0@%=O~NZnbB%W5lu) z4>rTN|Q!vfg*Ea|U8( z>a`Z((JjnUoH7a{;q!1}G{-*o$!B8~#sIr~Dk^A7+I(EF!w7h#SK*_uq3rc3Zn(La zG0TV!hvg|?_r_-?i9(P++n>R-q;lRz z!N>C_5q1(#J(Z@tjlE$%mubbA9lBA1uNLa0)boGB&u~LBvyxQv%LHHB<0E`q#!F;O zTeJR_{W<6wTkSv7OZ7s?Ir^v&uIPdVyhlFt&y2{)2uD5skaMVmg*`8Lk7h4_{nbQ> z(#YJXSG4C_-Hx4~O$423s?I@=V7O;oUC0{f#n>I$_*Bn=u5QT9zQ%*}^ECaJ3QeZM zBNO3b(D=Y_=5tfFS81h@N*_U-eSPfHtCpcfY`H$xx5+gOPOkepLpsA-oI6eUVySY; zRRE21HC_{C8a#B<+LqP>2#$?VQt0cfW$qA)y2`s|rfYsK2BDNnVV z@?UK7Q939G+@KuxGEhM+;R8?)UMY_Ez|GnrE&W&Ws6-J0^#~CUh&q-8hGu{J2EnKr z1Gg3zoJtYm9z)=nhFU@~U(?JO)MR{pnh@zrrV1OkFjO##uu^#Q69U9^Mjldkdx@U2 z`A*q#j(;?eUo~HpNLHQMbetl|kPsIU7s*VnXEP}K>}Pz@l3F!i;?$(@$23k1^9TYD zvdW$_Fbp1BUtYVDa2V?zC@k$i7THPk{77<9g=CSqW;e|!qeaeIe!nriIR*B9?xYrQzC5Fg zn)p5Y#%23qAj^%_M}JpWw6~qBeJTw%m zZP+2uH1l=U_>%u~zPyo)>rJMjXuazO_MoO6iTRe~V&FS)>P&$9vzEVL$=d!WXZk_{ z`}-tY(V_!?_L#WB1%IG8RcfxpDU z8QA~CMnLhHXS7`t?iVbgIFI{z?901+5(7Ej0#6jT*6c zn*qzJ=C!-gCQ)Jjvz4!`D)9-Gq@$({7i(1t5Myw5<_A$97M@(=CwZEsv^7cQV!}oe zjze)I>~$*mymNxkke%1a^+lI5`;6QQ3TOH+t&m73TbF5cu93+y}E1p?~etbhATOySKZ0Rx+43@rQSW4%YFK9B*O&A zWIk50;ys6Dubi8&fS43Uq~EE9+b4Cq?)kAd>GI`V4@{llMAMdus1%lPkg}}1yS&d{#vXer&wmsv7hhh}`5-^)4V5BYRB#xVncJ?&aczdH6f= zQO4XFHyLv2o5FhM?fwHgV{gh1GkvXupW!aC+h&>U@u?5zsP8*JYqV5ph!%-kap8$R znxolGk+#?w-@Wlv2fa{js95;v7^2X?tN<~iYjpG zXfa4sy$xeS{^RM0WE`O ztp`?2)4jX(eZ~a&kNtw{?E$w3XHxJK7>C?bHF~_=w8A_ToU718^*EXiNkHmFDnV zmwY*vR&ht{S$9<@ij?aeW!Y@;-X4g;2@3{N#DZayJUzbLM2339v(0__FS4eAM%QUKg%4RJr5TQ@m}dfdW^_^2v>`pOrsJMLK2wPYW1| zn65-1HI)Y1x6KUa(d_}={&|Fs*OteB4ZgUl(vR?UMwQoW{D)^YD0@E;&iLQ$Fwo8C zFK|1ww6x>|PdWtEFZ=$ts14FLS zL8#x299wpvzFyti*PsuivQ7Bfw0ihWtq9rSo z$2yvd1EM9c8SiKOArg;?aA_QHTjX|kyl18d`?+F{g_4zdQXv>H*L=-to@OT!=!QP45!T&rsAuz;;#|Z$%e7Lm zQkx5$&-3(jIQ%l%%G30WCfds#bmt7nZS*_4kHaV?`gYT$h4HNrBB3i(L`dA8i4_B2 zh!%K&9yC8~_dg630}uwt!ePPnf1d-(>=i?|CiBFFNeNvlfVj`8{lnLk5rHL}Q3@CP^`W5E?mr+QT#z#2N*BBqYReKKu7Ix#mBBf8Dex zi_E(wF*&P^y&uqQomF$zzu|Yq(`tXc*i5~O3*qsCur>-E>1a!1ZbF5Dv0K^0hZ>cG#cC^Zwt-d5yzgO`)ek@ z0LeYXw>I{jTB*x_h8JvynmcJ%a|%>Ka&D050Mrh9d>Hkq%btaHii}D5J&5x3!1{jo z<8B>e&5p=hf;lFzfc6r_JjEAe;YEg*3e{?=PbVuTbf?)J^*GrCvnc;mm6W64j}AHl zcLp3^DIj<28$x^>6nI_#1AV`o;#jZ=JL*uHCe2m$9a848){!f|WlE>$V4f+e5cIlf zr7eKK6q0Mza7`Qvuc~diaH_Gkr-kwE6#>?Gts2@8t=TX^JnkO zgFD)@fM*g&9thW?%7z1JU#ZrW+5Q0s$*yU$k4|Zr~5_?6s>lSCYBqQXwX|?Q61OxZOu9nQx zuJg7S6#e?uIW0&v{yYM4UQhWrM&*IR&xPKoeSoLPfk56yU%-^oiBMr%rF!nY!H941 zAj9%!i`DxxX{>&M?zd~?@53S6z3s6=OZz{2;jzE#Ewb{yCU8*4uLPjQVnz&u&%OR@ zPP%6Bz&v6Sc=ob8*|@CivxS1XznZIK_St}kBxE;n1&YcF_B=3Qd|=0i=4cd)y2boT zQM3KM)8H%TFDK-qQVhfDeRj)UiE;*Hk{~q{8wRL1Y`Qh}kg1&7LQ#wH_dM3TFjc)? zJPR9kjl1_;gi$(bm$mWA^4HLva&@+(3jXgK9qiT#H`>|-K zHFGdDVAH&o-xh<1c%j#vShzc*^N7FnpeFX@emvZ^S_B-j47b(?C!C%RYWLx28_p5mt8 ze^A`z*`=rExERn(?hJn9bp_NgBOS3bJ7I`9Ng!!_Z>`iCL5pYooS*BBc-=w~vDXX_ z*v+z{Z`u_zx1+a~8R=nc5Hv*DSo+ zZ!{sL;H7cct1Muvt8}I^sin|kP9Sn}xhnJP_T2@7TMKVCL8YET2*G+o#+&OT8al$6 z&%%M+LQe13>LH~8}gy-jxhN z^PD&}0R)&YKX;p$RC&wq&5g#NaTO?xsObmDyy9?(ye$%4J#`t&yW zbKcEZ_$~NTW+P%K|MG^}&Pumlwot8)@ta?U_2TX+SyG^}*P?^``KuZ7$8Aaj!u=QD zs9D%<4kREn7rj;&;UnMdoo%{UBDBHzzz6wd#W{z_6#(uKtqp-20+m@z9uYqW>%f1? zm;b|;2O!tbu5djVGpqg{g7?npIx6VPtLdj}U!|2#vuNcTI?jv`RTnt3hmq1N2ZtGk zAq^~R82{DRE>_|>Lx^s#uCT|9WV?O8lH9QeYZ82yfukRL(pcf%bD3)#`Q(={M6l3` zn?7=O6>>Gzf8|gqXLH}=!Dja+w>*-Xrjxn4|G$zi^Zx}SK?Za!$umLEYR)SbgYd*H z)6Ljf2E!MS2Ua5Is>ZYGSpSu5`!P}`nusxi{P9q!O!Hnr;s1~5kAysPZ~Q3)YPoD| zWEbk2-5z))RH+OE=%Dt!ufX;oCE%B!N)l`q98?0LyE$D`q3;;*NTrpl%zbC!I<)}+ zebAt953K~z{G2(#&aQW_GOf?b7Sj~UC&HP#Ny_OxqyQrcRO&nL3e>4}-lT1Ev3E}~ z=-Mx8NH9|~)JH7!n9MURoG*ZRZlWlo+e6E+=hV@Uo{5qz@`}ea1<=zCj!?o0jCxyX zdBqp1t>h#9K@e3Ch?o0cFNr^#1gX}?na2Qg6yEy^W@LDFJc%wmWGN8x5gN4LU~s{r z`Da13!@fbY;yrXAdiy+PRkFhdb{`vYubA)}eB1!OCct;~J4;x)aIe2k6V&sR`uuJf zD>N3QuPNA@t*bEdP{m3};UbHB2TGAc3tD(cc|H>^d=#pdu#c!bctK(VGELWglRNg-gBs%}GmCxYl7hz2+JfdW1iEsZ1M^_1Y z4=j!(;pEY;90uii(gv$gAD124~fNt93rZQg%8Kh@Z>Y z^ejeh;9WS}gF{L5-6L)e^4xxTgYIVze>9LU%qs4cQ`zuvrvpC4Cr6vu!lC<)d*CwY zD5&Jn!ptdDaFGM9YTExg14EuGe-o#vriH_23hzs>bC3%V$f$i_@I9a@^t9xFyF#Ts z7xSsNgchKi@LP>U!Yy&>APyG(emh&RuW+um6}19bz@-|%Aduz8Ge+}0jx%9i;cs*k zL6&L15dxK#kTDieY96k8%OFiz$$Caj{>J$kM`j!EjOe2@=exmk&A49v1G?CEs*Jls zQRqg3r(h-|I2W5y5pyY7LmLbc8DiylEMJ@_niKRm@G9MIdDw1&7Ect`JMR#+yS#P# zQ`(4Qc-ccR!vW@~5va)!frPl<-VU4NJ~1%)ajOpCZdwZ|2&mMflov5Za=OUdMiexz z<51Fw?=J07te_RG`?O2dnazxBFt_Zn>TCHR%&e0Cu`Fx83)S1TEE0sce?LNB`I*nj z_n)^+p#`A-42;x8!GC#goi3U+6oG5oVJPk_Vk2<*`2qKyk@g#ua~+#FW0QXI7^2qK z(LvkSZv%Fu_p3L_5crp#p(@aA2TlNnlHux@N62Nnp|XkN%vddOLoJZV{*bBQ9^jUf zv1J%%oRBzuH3@_`hY33PGi)P>qs=72!q)BW^ho;h2?K=?v6W$vUS1~A+){^gmpQj6 z(G<8WAtnQb&uAXc1x+r=C3aqZnQZzGDmG&iUDidbsrn+4(C}=_VDa%!KWmS`!`8Et zF1w;^Hc`s>$cTiZ!IyrXoZq> z>LWu@o4^SANv)IMY6bdsQ2}mKQksLNa_3xRow59JWE`ieKAX!v=^0pNRS6JNJ{yu) z!iloo+wY0H*`vjSbQF6PEgSeOJabwUV?>h!w)>M4+K!|qW z4+T=%{m(bxk|DdfaX4le6HwMF8_ws-OG7+%(x1s2`3uBVgH5UXr9vDNb@r)WTqqO?0J~|yBRi-X2`~YJgDxT;TeHUqqM4`r zWL%~y()T@XMk#x7&-e7qw6N;nM+Lvm2)LzOAVKh`|I-43(+!yt2@C;j&bHF63P=T9 z(7)pz>W&9b$XecVcSJ(2hOhZb&u2Cz74CH4CtU!Z+Vy1;=yzWDp4-S;W}c{;8V%EP zbOm&Jgd9-5uQ1cQjDxdf^F$ zKSs-J!xO7o!iUDK#oxrz(J|vsStV)ZFAbXcNGM^XsmY%OB_FKk07Qk>!%u=bkh=HV zPK41kD2t?PRf^q7SV$`$T-fn{6%0R9Ze$i&&RDN+<)~YzoHc#EN2OzHJYxZG z4hN}`;LqcLlic6Db9S`bU9`$-dag=f%IXNx7}XtB8R(M<8F*9RWD&7bD9S8Y`?1N= z1JS7~y?LFgJE+TFvz@bx60yYLE?o04o#V#+HeYig4GlmkpMMGwl^gi zh=MO~Xu%$hySb;W1Mu>D9uWauWF9<4jz!siaq(BJ@jV8yYvgrwbS(1#d6=LS-eNXs zWl^&+P_N?w96d5Q6}y~?OtI~o2yiJSP!H%$XK-SXL$sCqu@Tt{VHVqn31_DHL#=v8 z3`F5=Q>ugsK3y+NNP@UEdz$aYuMrz=SVRkB1fCTZm1R-XIp?!XKG9^VX524>R(y6~J}MUyR|@xpAAeyLDz=G&mzJcCx*8Lx+I zMw=FbZ0gc#=Le>XE+nR8>+|9zMJycsUXOVdAB6Wx^r;hlhWMy9w=_j4xAr44P?7fj zAOA5+SqbiQdkuMda;nu^$9+CsO|&NW!ciXi)X-C7YPHw$b8%gwgzP$cz4T_c%f z(vrI-?>_u5{1ndSnNN9VQF~*C#;nnx5b$Cu<4U-;K`#smUV*qAqMWIir6uVsrtgb# z_s+ilmJJ}32KFf6aYSt3mrkpt(+($$mW_mf(CP+*{vaCn0SjeWdgSW^kJ~OPp4mXI z68fXi`fSz{`fq*NT?8Q{?>#*NQLL&*Y?4#x^fRCd22so|gMXdl&Sl|%Xk)3Op&f`a=k7_Lm?MXPx|!l*E%fL_GH=ugoqsup zXx5%ZOcCkXV1=BS-*Ukseg7u2R}nGk!3TWZrESMWgHhY*gfe^)E<~vD*6!J|+&k6m z+J1#w5nS8Xo0BIuO+S@8;AtpgOO97dt#j4J>{tDe{glLj9@<9C{^qCzHY#d_2jzsY z**ztye?{e8{ZuWd$q>t3v-6e2=KTr$(nvdd1*z*FCGS-TfQUN|xT5;K%$(;RC6?#* z!GxpvFa-I7S12x(mq|;ZRYXU{`#b~q^Nks*WZ0MX73P|RE&BLdLK6``n1DKzNXXhn z=sDNNbjEhc&^w&Ui2Fy}J|I$@w%LA)Pfc=Oir>D=!26`+6GksO(ZpMmSzbZY0$X*EcX@mhY0HS=m@~+njyik%#x| z{jNe;yrkpgA*JDi;@(e)6>+8lTx2Jz`Hrh*WYgF+ss!Nt>PJd;=80A$V5>^(IFG{R zZj$ZBxhW=;#SFI`8iL2=UR>k7e!UFEV(#O{FOKmFfl^8)O<(NaigNT{?YTT~wiB$` z+eK0-gA!V@Ka?$#l5(ng`JEQB$VT~WI8$B?3CqdNE@?3%Iy3XKEPjX%tZ4hrxc+{( zP+3w_vZqsUS-;w(2i$`}D2t=Eh#M@i^8ED+_dPriIp#*hXkAKyNfS8@cK#j z+|zEk@51)X6Zd&GJ|Y+DYk~iN!zttb)I$M&oeRZ zA|v+w=RTg|+gi+Vy|hem;lDgINN6CXS&8-(ttZkt{EWO_;q50a$5@c~_6&jSA^yih z)+VATFpqbzTfH;Jp0D`vkEsFyu38+Wm1ItUoj<}cJMeYFm?n(1Zwr}vP(7s$H45+} z(0cFH6~Hy=7khcR_iuzi%R}P}+XD`AC)(A_QNM~+OvZzVwbOXV*IJF|ID>t;osX#JX>C^po6vZaDAyhj~E<1*(95*rPP83&y z|5pU|N7BocrxH3SR0|=<%AhuYV3@u$sg{k=8IB)eai=R!wJ0GpQTx!iRTqRS#98$o zyk|uVD)uc=8IfgL1kQXUQtc}NUVXJ8KI(fqd#}wpXH%{e>bvG%e@>}hHZ$3==&k;v z-tV0fdKVtLrfJEHSdq9N;xuWv&Db8=?z~(s+fQYYrZ^5@4Ce)|oaQ1^8aTo6s$oW2!9XWfZ zO2?tDnQj!F;7Eq^g*1ej!ZN}ovEzw1M_)^Rq^YX|pF__Tr?fk&xDotfRrAXR%+gaF z*fTKd|6%Di8OqPm?HGNcX+C-IFGIXVqR3-qMi#OGSHMbxLsnO-HBcvWGm}5DYYX@) z*^P9Wi77-F6EiHRDqn=)fNSbx60xo6?+u`L>MRB$r4}DV5$RkORB=Gw(aq(@`G1*h zl9{g&8aNl^a!6VJJuqGNvk3fxw4!|-YyzD6QoGDeaq8@ss&XH`-73R(A=Ew$*Dh7- zcyuugD|tFOM&^xrbp^Xj12t0gLr1^d1Po8@aCF$NC6`;4^!%YtTvq&vR31-wyL%{G4cn&3)KNn2|g8fguvt@X;Tb; zdHLt2F>1}#cU+N`fdZ)At|U<`!kY4V+jz{Pz+}K>nlJ{gRN8M62-Tus8*bQxy!9I|t zTDhu99ySFr;JCh7hSb#IKB@EohS|%wL>r1T=f;q&ytc4TJEBsGkEHzL;2^ioZm^jvB@m!SYbp6$xhB%EMH>q&L{zY=+N(|YwdGBherQN zMP$*O_DpYhw?_)6k^`KKNN7qHqYuEYaMz^e+jRpwzs{(3Z$;VbO!*2=f=gLqa0OQIW+$nUMF81GGP+)GdW&lC?!`8*J#aKtaCXmzuR;}{yff?c3PT$8^-6boy}Rz zoMtB60PtrE=Y2G%QZwz8Oy*Yal$Z30S%0V_d?CkvK+v$Fi>+pzPQdo3u>pAjf42g( zHl7C#s1ivY!VFLDe4-tNU2R%1x5J|10yLYvJ8=~pXG>|}`lxarD**=zri49JK=pbK zqEr0n#s@aESL34#i||Sax1W6FI`Kl+?M^njcd9JR-i>=gI zLQ~xR?VHI*iyFSI*Vq|oJcm+DgP19VkOMxq6brmH9YAEzr?s<2T0>8n)GVy%J8t)= z_Z+DA%mwm#y4Xsw)#Xv02Tq?k6|o{eK*N$O)T8y$^t%&o>!s?%^U%arNPJ-9fay6%f;9-BjLEjw#689^CC?J|-UAF_ z!^yezv8A|fE-AZVmU)QkmYeceSq~cd&hS7kLFI8Q3Gkw{P1_ll>tr7P{;{o8O3&-xKFd}TTH zQR4N_YF#>c(Fh{c`rNSAQtJMu^?hl~*qD%{Tmd1AteK|+p~l`ZbZs_KlZELbv(F(Y`)DjpH!I|z{VOpza( z9{4NEbgSUt1RaT>^6Qhya4}|8#G*njH(8xpx2mEEk{*@1e3#CmUb&pU;_S*UM`#T83^QW_6qw`49!&>e9E zyCPZoqy0jlV^DON3x*r7P2*U4c*(4arPwoH_~tegz6Tczp;#-Pr1O>i)R(gU&o#Cn z&*J~ynVJkZhrS5W$)1EH7?=l%jcmPYCd(_;VZpQXap@q!;j~w7t1FcOhV!=Bp0=Ns zAlD!^GNY7O6a|q+Q1PC9uY!L^=E7Zg=f?#Vk(SFY^(D*QV3UO1gFeI5$4Nz>(sXWy z2Yx&B0|B{#?5i1{t4g$)m2KDc^dUF;?@)pbO3~;89T4%n*9kL*%0XEpfNOG- z5Y!j+Y4vi~wctT%5l9tl`(5>ZIm#~t`l0ZQP_K?pp=JxN-t>7xEq7*35G4qD>RGk= zLieuR0CEC5xo~&*W0IE-I)bqqKlb7qSD7PGHb)|^a;&R*pM_uOA88aSaOVIh1yp}l zdN;nj8!IW3n?U&koA{a?xlz$U6>~cn*KyiKwh*63rNF(l1Ta@vfA|=BwjMr)(;Dx@ zPBD&rw~K>rIAl;|Tq+>7>u-IXaj>>(MQ2*6A~Q8E2z~ zvp02^l+nx)tu*MjqnF24x7eHK?k>q)$(H~lKs(h)JJ}twRMRIqw^m4SqxrD#=x;G3WDc?q^y7INjh@l0)ldyMG z9w?;@bWDG3*US=$Ir@ExNV@#z&VE}_Sdk&5;c|bZRcWqXtc7u)7Db72>YT}v5UhtuBDKr)JhEIm1_JB%E&4S>P9RaeLk8LWHq}+(I`gVh93)7t%s@vYn(Vr&r zRK(^VrweqfF9Cd;%!gA16>9Lp1P)+XqT4GK(+@#l`llFRm`^?Az#oQJ-KT|EzV-n$ zb=Q-cUto*%F=E>OXqMb_{M-oMRoS{yNh1>D93;|$v3ZC4YYsJwLYXi8ewr$(wFfcE z*mNG$5G1lun?V>Au69~Bf)_+m$F~OsJ2V-0lXwn}nc|lT!Qto< zl9BT)0^ONVx$_-X%^45-tTJAUqO9?Gc!3aKk-CYJTqc15=(Z?oGp+uK*Eb<&(GNtI zi3+KU%5ZWHH43BM7kcb4@Cmi@ukG8O1*?8e^n)_DheKO{)H^U&(y{6EYNNU?#WZ)< z*pvN88o+wqrtoMp&+-zBN$nuzA(>qg36}&4;!$0HzpL#6oLe%s(pxWqD)Td;E&hYZ zf8KOrVAX^JN<0x(Zti_vtPELeaNh-B4-7NT_z(QrTh6(Z48K5+gkP^1U0o;XzQ%Sx zlLqLmJSzlHxv~Db^CdDH`GFb6Cx=zdmcx6$tFaq1y|_|GGfQUlmtwvnN!8mw)a*Tb z=q&nbbx`!k!>`@W5r8nlWPY^GOW@Xhv!Mx|x~IO>czItNqxQkdZ>b*JzU#uXk^zwd zJ%R9FtuO6=-f4CSEIp2Y>)^*i9cw3QVLg><;9uaD=~q#brI4$|j2oz0_7P^Z^tOei zQo%-Fy3gci2~m|MW}lT*Ee}==KRA1t>0a|z?o${Gv@P)r60Xkn_1Vls#37#6x18(F zYDqVhElah9bAlAcJ@F7%C}*TLMd$1V4u^_krRCZ#@o{IhInTww)kbEq!z9~|%!Cuc zfo43k0TM0AzRZ1BoXY00HB<4j_6Gd<2LFKt*`9uOtssL zPWfC?+4~_6bd*%L(pk|K@NpnvVd_+ymB|{U%5H=84#^hJ`tSdD1`-m+0MQ9*ctV1$ zaJrfZ!?NBRU+#8%2-$*nv00NX_y<%BZ|HzJKOhO}MPGhPl^Aa2$Kx|T&%ciG2$GZL z%Z92!qrQd-#geW3tleqg{FupSAFp&QS%61rb^&`gw>|yNkAg09s(wk;lrM-l=c)fS zd#O3I$9**JK2VwE>RMLH4vekoHS5n*qQ^jjgr=OHNUx!%d1_h-lkWiFfCRS+fk_;% zY2I0v;Vz!@ShGz~tL6a*!bsfdXTT2&s0>=RUoCTf9}ddZxqm3N8BnHzf%7*z~{!`HGxm&brm}%8b>;k%niBVSVIv}(Fe^o0+ z(tEg(um@<_lSm)P_Z_V@Lqcnvf5;zFSmVK1zkW0@5>0l~DvUhtx7SvE z{$)tw(H%2Cg1_Bm(|qWKUBwAC10}-*V?x#tC>$DYoaF!~FNo@w#Z&0vT#de=1w@Iy z{+indi?VxO7d<*aItypDCRK@;tG4WkQ&#=txPfQi{japVD|__7xyV|e5S zJi>`YfyFxAM_4o)5>i5?u^9YE!Mfj|DwKdY-123MQ1#Dq%uQrOW|3BNGMr2t!8rGJ zlnPQ^NA~boQdKQFLFUl~9*IgOV`1GMq!!#Bs3wKy8?c;MFT}dDc-^V4up5vHjcX!? zkD8lBp;r!*E3r+!D0XBjn*8GPcl~!Uu-(E-TA}9BL$OALBrEfsISb=tGN+>V z=yFc65=8QGp>$yPN;3DsMAcGR z4OahUd$zv;;+;5W52w@YPO?W=!m;@67DV%*M0{lY>&Z`rmnw0CMB&beJyU=?88Q&w%W_^d6|X z?Pa5$sM2%KAJAgZG9IOkdk)t7hcdBZ)5^Fc$(iF-+&vvQBhmsp^g@3e+_V27Op;&O zf-d>%igc1m)d4nb&qX3mp1ZG4zrsA8VNXxw8iidn3)3;&2GFu3Q15-I_pUjAZ1;9* z|3>mGe3aq-dP676?XGcflcvt4flVi>zq0`+tWf>9hCP%B`2)49`5bgSU7lc4(p!Fp z{?+rvbsL^0Hn7lb7>x&8CuJQ;#Q}%}`wfck99UtTggn5B0Ss3oyyZDf-F8CC$=TAd>^&#Oy(F zhf)lc11~IZSeQL@Z3y|X@pRg_`CD3}PvQ<=^fbPbdokS|JOk(MTT@i*hm)1vLblV& z#m#sg#Z62` ze-%w-Fgu3=SI`BCpMHmwKu%3mC_f~4Q7H`PBkS6mH-FBFnjVf&A`q`dC2yY1C;sb zPUDMC;Do!X<;F5*M<|lYLNDz1s1bz#d#Z3G<_AmeY6DWPmhqQOs zq3&ZkmNi08dK@46r5YeS&8GuxYO_%t6}BBeh(-!9ui$Z&1nS_&XCdwZYqM+Y%^;il zcGw zrsICAF3{7Mi4upj93H?Jc*nkUb`U%-=JRNW70b6h+xRn7L#Xa5;-X2i_i|Bt<{xiC zQ!>G@fOA^k^#W!fFUWua^$=FDhK9s+L<2S)mEpJHMWR}u>DB4p_+76%t+h*wO`Gj` zuVC)2Q`#qCgD8>L%+gEEm1Lm0d(tjW%Ztp+cC{?bcy~oEBYTb9 zsS--i-i=XG1M)Dbm10>57){>=DSV6azWRp@KBCXueU8Z;(*7ML-8p4--@`wK6Joa% zfm(J~GFBduHt*BkVc?vWe~lqH>DjJbc=fIt~2CVf;k;%+6MGAfGVV*80$JrMKy zy9G%KGzjuNnm%>dOgTHJ)a7kmiBIb`p}H{Ea+zc#_1uC~03$k@=U@{>6a4xHrrwh> zisiSybg<E+BaHz&&k`CP9mH9z%`+2~|=(wRZ>kLv`+3>Yjqc`9TGRrIg-c^DO) z1D;S;2=|AGY?nve3;oVI@PhGQaPtJ#2%D${e^K+2J&jn3zO|FN0Cx~+_A)tr9Frt} z_~lG%aOmgpA@R6<|K`1|F}F?qH!K>4jh+CuxKgNAF6d~bU(1p`UoUdTXTNlLrY|YE zco}b}OHUb?Sup`-j#Ci_DkeKb(F7l22Vt-EO-D>gb0I8>%sI3u^kQa!GzI`YFYw6* zTG7dK><;gy_5p0%tp7r5xip^yo_ZCf5aCvc^L3+T=V+rcO?2s z6|H?eb5c;=Xnmka%-nRf7onN{82ZEMzN<8MjSg1UHIU*HGUvMSuaU97k|28qNbcmz%hh^f*^6>ueb*lkX}c`oy7xix{axCG(SeQ}{nRrnK z#lfQQ-TmBq%x^WxN{`Pw*|4l|HGjLWs8s36qRh1tY%8s+qLC}j_&i8(KcCQ`E;w2_ zv&IK5QE4zj$nuB4-oqxEkK6>v4&R@Q%3=eold7`p;J>pDxy6hT;WX@wO;w*}EJ63!2viHN9>3co^!V4G)(+fy-LD&GJipaK zxqRfeE9EQoE$<)r#VI}Ty~Kp1bYkcm8~Qh2Ksn0lvBxwm60N==SbVf={<)%AyV2G8 zv9PK^=C8!c&E@R|@%!$zz&AfT#WU#lqJ5pbF)uJN?^-7Gmfi5Xq5GpOgfxr5NnyF` zqkO|g{g5LBF$X7-Jh9r3yv2F*XV{vK7EN7$xNl7Gs=32(>a*U68Bm3#A2TDYCDq;+ z_N1G*GzrAkNazTR1OmZ@`X*i6m)os$QI_+TRu>Q#@NV?vz}iz%Ju9o7WAFQo?<06( zi?TY$xLZG!4N-3XYz>ALaqOBL4=6Xi1SN~D79G#a_|CxAy15zxvW}{9A}!;BweC)) z%i4q!JW-`FezKyK!G`c@(x1PD>-TM#fqHwS*zn3R?NX0?D5;$dhs@f1L>%i8o4B3O zstvyHt#aQ0Cb&%co2wIR1w!vkuPEnz-+stj^85eYfAlA2xT|8TMrOXk*uV))GBAym z%L{8r1ZMPwP*U2A0sp&jv0~ zR2QR+x}#_D!KYUh(emR5rB%b9FWwAOaz0qT#im%t&3<$GTjCRAr$`OS*N8qT3+6uq z{+Y=qe+}Y(3xD=39bYeHCU^_zL-qhDF8EsT-Hd;~t5QQWivSmP+}Ud%J@d>!W}tX- zwci`q7<<}YESsSyh0<;T7ReO0FWWpR2|$|?mE9HJIM@2m>=jLhlfDAOIsk7>*U0KG zpd*Wi6K;PdC{r{+uq~sqW{a8-y>9;xO=lSvW%qUQ8B!h)6bX@*5-ADk6r@XPXb>1e zM7nEG5Tv_1MY?MUL2770y1RxLa>)1Qe_ii)K5_0jXYaMwUccXQlw1k!|HWc{5L6GE zO!iZGL>ZxHJLGipeT0(enf+vZf0TEBI9}-kCdwzVZXC1k`hhRE5Z-LzEg33K$27Ju zUq9zIw~UlIxM$+>RG9gfMDzNDn{y<#x=mF7)w87l2BfGC;&kWb)w(1}T1@fU)x40e zzEH#>eG=%2X}34l5s3Q{C^t(K{Dk6q`P3t@kibyl)YUMkx%&9@cSP12FoO3-A{xEY zyyEKS&UnA<_VZGTTB~Q(t8#Gpl6gG+1~5E7PKtP0l0^^D8Rj-{`TGD=LP9y}Y2n+A znu-uM8eQ%i;jNS>%sFf9RB)htrYB)EoEY~IX4fZ!+L0X&^HAg}SpnJPh&`Jt0!m|I z?~BE2&Ep^dywK)#>i+WCSo;O?Fla%O&Gn23KmDe-D^d{1T_O}+?$`z_)axFeV)L$s z3<_c!{WU6L1E=aOnH%e;BCdV0vvxBqP?x?VRXv2G3~t?7b*b17)kJS#2o)zfFjFKj zTnKoEuqsD00V4(}sAYh&h0i#hsONm{Gj&J8NN{@dz~P---^W#?%y^O7R4a_mE0fKE zDP=+RHvk(sJRa$QNo_deJOQh}lKNLXo)|X3JWrF9>18qxy=Gnz6*JD1FL5(T_ zF*L_^-rIeH1bZGyqUSGt=8!uiZY|hM{YiLl{SB4M{{b-!{%yG3R>>JuFGYT#KUD45 z3a}e%-gDkiuKd6Vzj@+7^e!!}^d8}my$t}**d>&kDIJL9#~@!gwFyawob2HxXrGqv zM*_F^ZK(3)+xNsn0rXBVrr)7X2w>^oF78XKz(WU4%wEtCauG_HD-DrA_6TGOOUmHJ ztjOzmCpxbGafYk7Y$(-1>)yZn1^qDM~lau+ePoGtc%z^qaJ}3MQ zRga)s7>C&wkq;fA$bRMJ?;pLrz|=XhTlM;3YuC{4HUjFN(nV=ClnQNRiSB+=kyrY` zh{ApMQ`3&Y*p&TH51$ZnG)QIvfDy)PD46u%<*;*f;P#2vGzZ<=VUzxjbP)oy90 z@k&eq@B22R1BP4RWgqONE0hQHU*Cr>m~4`vgnxSm3TR&{If)^6k>aYNY80lMFSZ{0 z_=m(W7jI%Q2yM zlCv8Ck`I^G$HB)ft2K1dJyMYGwB=-2WZ*2c@~rpFFpudqT~vB$O@y)mBB5WHbxveM zZ;A^D4LXizVi|F&U$gV9kIVTCkwu<=wsl786}7*~4Hk9{A4`O`TG2FOls=(eM)dEC zLL_?*T9EqhzPjDxU7Xo>B#k_-NQkBbR#%u99~t%!y z@(F-j85HbZL|Dw=sO!O6X*K~(fQ@+`P%9gq@70i$eL@n!{%Zz65k-sM?p;e}-tVQ% zHHnOyQp_}8Q*f!vV{h=>trJ=(WgCa|{)_Q)g4%h+@>i>7R>zOqC)J3dzs~l@osKx6 z7AW`;7IHinn zn2@WJ=vmf2XB&6wY}7;O^SiIJA243hOUYQSb^db~ry6c--eoZ%{3xC^v$K^&6}ftZ zNpG{<9zkGbJX2sT-Q^T9mEexp(rpj(_gCmE&fU@9KLV>Su|+c>XDGYQZB1;Dmp@-e!hB3a=!xCsifd!Gr+}o9hqm zrM|J6l@4!k;_RDc zmqnF}U!XBp4VHvoKTKOYrDA}f8uL8AQ-L;$htxa;nWBoUDnPWzG-m?}y-d`)OWf?0 z#qMeR2P-2=3dH&P%=b8^F(zq1nG)G6juUmQUM#N2v=h87dg+yMJ8?EXsS>`V_5WJH zCIUY%(=(H8gjEdU_8$gBG&FW*V*oHs{@lmdU-Bpq$1@aK`VXMn>qe&!XR3rGZM}9* zfS=>^Nx@|sEBSn3y8vR&0G`rl{@f!a5bG)RZ9v`wrw?wPqzo50Nat?_y;-rZsNG3% z_ju*IUK53>QBvCc=}5Vl;gIeRm?<9)ptc9te0|#F-qI;Nx}L8Y#$Pm+EV%{T&o5kY zw}q`q`ZRhY3g*R#e8!NUMD;CZ=EX z#yi$xzK0uuo;|^a?UWcvq!XxN{M@z{V19!P>Yp)2|Fs3~wZuiM7LTPFGaeB%ou6k~ zB-i>}p~O$Y`}|L&VR0tM#4i7Cof`TgQpr~_D^a4ffcjv9&#gLXphqdBabQDc8dkxz zV(8K;-m}VP$N)|y-}7h@+ba%caosJT zIxI6m98G)}c8s<4W{$s&+j_pM2mFTkN1CFc6u7@cO$`0fI_PxIa<@-NlJ$h$C0Ge= z8j!faG?qTKJG)m-WFAf>l$##(z>Dfc>$t7i0no`!T9i%F@`LBf=xb+y^(OoRulXGC zC4~odN%xv~>aqHJ-QSv@0B;!&9TVeElszT9Ih<``%Bmw9BRt){;BmB57GhX&ZAC-& z4w)?w;p^Ss-`tkc;_nXxsHI zNU4Pf^_rHE&*SP+2ry|V&B8L-F+YHa)v-zXMe_1WRVc>5l^_n+eBjse0I90{m|s$& zRQFfo%fm^aa1HGbi#g~5mj?vj28j~R9M^FJ3|A;_oC(?j9p;Vbx((Y+PcV4_N~oj_ z=q8pUBbWIeccwEb8@X*kty{fZUtFZ8)xR@V6c*pF4ybtwvo%EJ8SI{kHIEPDi{tj< z;$007!lnOnY>?}soRVq%iQNs7vt z(M-uu;T48Xj;pxO#o}%>Unl`P8Wrts4gsq6X6HNubVFwnXMRF}YxW!GbG6Xn^LI>F-P}W$kuuloh zgE`^s)s62-iq|Y!6M%kf?q9#=w2*RF`V zfI5M=&Thc5@Oq^$9up!T%TK|R+13wG4OAoJ<0k4@SRk@`J>KK=IsZ|+qmEI&(-`+!cpSC!w6>>0YS|3sw%%ou z3G(_`7x)R)@P$H)&z(t7CeGo z&h)bc8bxmI=Ng@es@)-(jl^u6gkq&cUiLl(;mEKuWdAod=5}?NCf%06z~La=3BwDW z=mqNMzWAs13FoQ~?+HJjQ6KLv`{o(g>^!*PmF{`V-5WvpeM!y0&S8R%$*=z&_A@-r z+S{3Fj829&v^it#`jM}lVo!Z97Ab4WTN?v~Xgn`-fDmWV=V6$wjA1O*4$JFm+LNjk z0-qWvDry!Jp_G|UaItd!vqcX?ElUA`02zYckXrrfUxlBxs?*Y`iv14`ep<>-;XK($ z0uka5fM3;o`&S7zh40!<*1X`ZywJw3UXRF}m#zJtXj*ku%-*WP?yFu^V24rfi!&-e zelLwDJslUf@}0QJJ~puRdr*5a98=NiC@eCPkS^})sSjg2eCFcu)5r^m7Wt2+{M-2w z*F#nx>Sz>8+&A@T0SHD}Ep1nh5EYG|`j0}O{{umTv9oz8zqijcll%cL&Im_Tlp}Ga z4W_<>=>S?A;r5^KNvJ#0zoLcTBYpI?#_E5OZd9D99kvg-@p3^vt9dPZ`YvEPSg53g zF$y2Sfg53nlNc!lazbDHKW8*#)vx%I6@6>d=K0G2G+38Dq zDMuy~X6nQfUu|VDeAlD-MblvOYGyJq4%!OD6pZ(KrLi#}g=AX>8C=!Ye#`=KrRJ-b z#}D~>6lr#KP(i!245a)#w20b#iwMs3z~^?_#~hKdH-5d=oVZsjr`gz`kU)#RBb4w-R_~j>hE-tyzTO`Gp(BCOQ4R;BgG%-& zUpMg75g&|r^!vo=x4rj2 z3{-CvAN2xU_WT`Er{_Ds^hz2-tP-1U1|6&h3?)D&Y{LVr7~dIx5m5{Cd4w}HQ2x0^ z)Hqd$_F4cUZ2Bqh1uc-pQmqt?J4_Y8?$z-ubA8Fv{TUf!?}vJ@R!F4V)EhZ0!`mr1 zZ+Uv@FlGVmlWyq(Leny#Ew5G7w;bEWR#K_k8m$MISi#ltWST)THv489`|T$cF@xU& zKgJ+;{LHHGVmwXP1iLjqPj5Aoc2@sK29-gOEBCkU$tJ?g;pSD+WWT9Oj7)S>+WcHH ze<>4a@$9Sly>ufreD`M7lCEVHXHVs3(m2j(90&U}iWHX68n=}$A z2LhlPS9c|PFVmQN?fty{_S^5RfUoR;42a^Ms)E;FS$gxQ3{sTS z?f|F1eUhftM|fElRFw7l7uU~Hj;);ViMWX~x~(#$w4XeuS{~X zKG)lM<^voJp;ZQ-pUTm?YJoydXK7yF54WTqW&5ANV!0gvmkgT|m5uZj9n{N3{cDNs zbvR()=Y@olhd~rkO|(znnuKl`Q%ziSJEom+mSAUEgNriprO>&jOvV3_@~50{P|4D( z(*3N)mK=PWhs6Y9>?}^)gi#&I;4jw=KCHCx2VwOprUx*NY zpXhJrmQ<0?kbBfeOdM~Xa3QNEzu1r51j&PyL^TZB_cE|`#YaIevjpgRZf9q#4SfS% z-48$Dte1xr72gyMxr2>14lLPSq50vpEG!op0fE>X`skeZMVm@honc`Bv%@&co!ZB- z4%U5_?mUcV5+WpLvJPZy;Dvpgj}gbj2?{i7-`-FktGPV27uCSDnl~iFSp+$)hb@`? z3@?$$;1Rnj4s+MoW&EGg-1GaNh>et!*9;M)s3hEqTFzoNwvXNPo$@<=6XER>$G`n7 zLEU8~c+N*;H+O#}9-Ma&QSD%AH{EGyz1K6s7jK;`W;X4^sUS1cdcEX~N%gA>s8+XMXb*z#PDd;o8og*yU$;k_3R%^TV>svtxduC0YP^!nn9baCD`lh z!cHEZfC{^7Se;i|_}tu?#&?!6y9*}VL=cPlNHRBrD1)dw#V=bq$wcl`+bC&ZVf2ET z!N0A4-mAq8;jrH3cru0yac}SVzLl^1<#&VT(F1PpVP;C=a5l+dc2pw3C4#+yg8}LM z+CeIbxm{U2wOA|tRa6G_w9@P|OpyZZzK$E3@fF@iNQ7Mu;>Zpw7vF2QsMB>GK3gFb z+u&ID&yuFeMCDLUU-+&|6i0rDM z)6mAr*~6&1DLnyM+DiX)|KI3i6HR7vtCjNd^1s^WX}$5Q3V8B+8|_fzd)HqVMiQLj z%Q8B~zZQKtw98L;qoP2st3id;Z7{Zgj{$7M`^sPEw3a^&fiV&I*rxA!@paMrL-8gm zoE^B?D}$=G*rh%`Nf~A(XE+ITSyzR17@w@Y1AfU_zFiwm^7{2!Tt_l~DdP#A^4Hfl z^>8>l_sN(kF-c16Slv5r4h(BPt%0*la)M{mf4_fM{7*|v*frC+1+X?G-(qzXaJwGV zy7IaDl3Qrmq*v~u9jjY~NpY~%^w)xx~f4u2goNcJ@tC~KmWALlEO&7cT!BueuSv%XE z8Xv@?L*^@6ktF4E>|axgfz7lp#1 z-d)PX5wl0{tdA}E>FqzfBP(WCimT?-yctwV_)woDr#DPO#A+pi*2xt1v1#S(6+Jud z#T-)~B^=w#?`PX6nB4=Y@PRTdJ#rpb@SV*x^Qox)=jP)4!k{?kVKD(_wG`s?Y#<*) z(@l55Ys&X{IisQ1Xv&yWj>b{u*sdyMFVBzQ_O;b7aXdBSRxJupEUm=d_4{j=KTosp zJ9g5RZWm7xJ370cqe|sjB zxaN{nani>c=gaGf{G;`5Q%lcZVWy~Bm@zBfIR$Eikl#aQWbtSuR8UQC_I=wtCl=LOdF#bJdmj6^*sJWt3tzi}7ykT-}j|<77_U6S0W(m}PPu&*4n<8J-T9Qbf z9oct&%V-RCs~FB)apoqH5PmC|vBps4kWa?I8awh{frd*#FN35a6C%5NWN^~oC0C(6 ziJOwoZfhQ3c{OX^{sCL?Gq**U*`4tEsB~DD;83_*$B%BEwngK%wT#0~LUe5Lor{}W zqTsvnLbWJL`=%yP+!!sbk+OHz^Jb}VvHi+b5!cg3$FVVMtjf+0$(9|Bzc|TmDlQC0 zp3#M|VDDPzox0vW7Wnh?X-iTxKBfKf?_{k2M%d1%v;-xkeY58%I}Z~X$Mn3V#p}bjoge}}Ht12yXBjChm5*uXMq18)3- z`hnr9-^)^|L5Fx4Z<7rD^xok8<@3aw*_1X29$5rI25m7y{u<5{$cg-6v`$(MEOrh% zx$^bbuRq205fixBz$kr_>2n&Rdpsra*n~+PCpQ`UuyOBz7;oVMt4<+WhM5&iYL~bb zF!XA7u|`=A^d9wBc!eXp4{QJ{SQ_bEpS@`~{NGJ@F5P(d zSK9xt*V?4hWQ^UA!OtRzw;Vlwn>CIjWf$G3e{(nd21Kl84{ugjQ<&wCsHYAZxX-Sf z*^6-o(@-7=GKR3?Xk0Ni(;xw}iG%LiUV+Wid%;!=!KomZ1|vDFGSaw-STS!75cFzX zHTBj~Pk*c5V9@4y%Bfo^pV>&eXoGN1e&cewvr;Mj)aEfuW{4cEryvYuN{9D68WMGS z-@O*e2Bu8`+Z3PL5{Aq9x?51`0hcD3F&4>Rl1$(8KnJnN7uP$>%8x-J_1qFC`!!Nw zkF-{V3Q3@CEyv5nBk^!JiOH||RJBZ!7=~|ytht_@TlU@TuX+IO@E(l&r+vTG{fALE zTo$J?w~lyC(ZX#R;mZp9sU}!3LOPV-?OYSVPiQID{vJzyhvi5AWil(Z> zmK*#uT9qStg*j4|q!G2_-BviWrk{?RCXu{l54Wknl$EhpTj2Ynqj`^`LMDxidR)J^ z;%yhh2@~*pBPA{)WuHZlL+_jt9@fFfZ5lqZiMtEhgIbInd}PVW$FWB|YK562!Z%)- z;ZAI{YDO7RNlApkEHt#U z2J!`0qgG-Ga{VkUTyL2rLwmv$?6abfmUag6q7&fBd||!^0i1)tSat5yR}Zv?JI9eY^5m}~c; zkd*G?nqvGLS)k{av$r^Zj#e1HT7H*!`Yc5aHaZL-?UC=F<*J+;scpoV|J8Vt#hqs4 zu;K|#N?dTBGW1rN!J91IJgsCP-wu^=2H-0|LV?1w5Y&CGzXQAG9=wlGGa`$7AoZ0#th}218 z#wwB5=()t+UNQE?6)+3QT5_anp<)S_sF9hs*KvMpk@px={%?=#)iHXj_tt$uyH-a+ z_+lRAEA1NDFB%5Z+*lW3f!f4YdueU;ujRxmWO~M_`5|7htk63R?Y?NFH8fej!z|w? z!7BeAHC^UYiO#+y3W@sM?)$@jLqNfK<3v?p(ZzY*c)^M zWGvd0f9#ujtTl9+NaqCBCt!`9!1W~mk6t0l!-ruzi{y_peNaELtMv117Y{mLimW;I zVxDe!{+KD}5bwIT_I zq=N0MYt?BQU-Fz(%~oENnr@!Z4WE<64O)GYQt;a>cx0le^UH8#jn!Sw;-At#pt<0! z2-o-Y`F`{i`7CZR8Y%v~DEwB}P~XPX+8Lw*<|h(aH#K7-k+Fi-kCT5A!^t@{zz_tz zfLSa^?RDEF85_0@3ApXAF!5oOP4Le>=8zJxVXtmKxSFuf*yWK785w^QG{e-_wD7jT z2*H1qm}EJpY}a&QL$^sy7eJsVgX0lfw1e@Q=;{x+yEl|w+|vp5*NcLP$^WCL?^ra7 z>61>!k90NU(X&|h`?7|`it)0EZEiN46kv0R=lR=AkR6ZYu}c`E`I%c6yG!2aL<@pYdPO}u%T1hr?tQsFO?Ovw=lpZ(cY_TdZi%C$*1>4 zg+tJTwdlqR9j`W{ymvF{gV=5n^5;Ii<;ilc`WBH_-zY4X;paCR?qk}eVz$Q7@OrZq zfB3rU;N7xJSbbY`n^vC^$6wfEju;OW&|_qXaM)#_qPff1`)B6B+Y-6L;7)}9s`9<9 z6!UeQWfuNGNzwb3t#T2B`)#Bs$^f+)jw`|t5{ZgwmpKo??)nYk) zVcHOF2Wc58D$P~3gY&{EmMo<5pO-C46{`9=lHi{JgqcW2y@J zUc&wxdj}1@*Pob~Vf#)%q49OJEzz*$z}0d&tFifU;oT1|WAAPI&p6hY$%NIU{;Wh& z+@D1)?D{R4D@#Jc)l80(P@WOwNe0hKK}vT}EUar*O_{4!zMKEtlX^Af z#5LjUYjv?+qCAhbS1hhms?56%m~v&HBk~YELlE^-7TDd z%*+x$(hT06rsS)kB9-VkBiOD*%_4U8Uv-tMd&pV#H@m&AH7{)-XPG!pcgg@0=``SzZL*~Vrn+XavpX4rCFTV;ji*rwvInU zeX3o<|C!p1tiQGD(UfWlzDz$R&-;MIe$;pDX<%OWuk)>W`TDo+3)^i%k)9c=42r?; zFnm3I8R*nlUg(q(0_>CVYksR5Z3!p)~-5ckYCs2C+u4D zb!k7j+3<=6YZaR$iys=lz0v?6_xK6?1>q%7m_n*XYZXO!c7E-B<fAH2u2s);}2vmc>v2wGMcq3+ZF`JlHV;irOWiv>Gj9d4g z_eP2oUaqI>*~}zM)fg+%K#gh3v?@xrhAb-5ZO4~@z)&)Iq%LDsx+*B^@QuyL>i zd~P{dkoVbFU4I}bkhj!W@~q_Jiwb$_7qeIn5*Am7Y{BAP^1vlY zM&8Ok)SC8q9ibIk_mC{Mmb<<@L(qCm5S{13MjS+~&fs|+f;Dc)ystq6!ML>APt+#| zJ!a>BN9$0(Wg8d_3TivlYw{tKJw+M0%@pX*r`->PD@-1r726lS`S|4M=^M%Mw=1~M zI?EfI)JE8kvwV7>4PwFM<8j@orB%BnnZ45eNp3oH%j%z zzF4GGy-6YCI6__i71qnQ5q+>to02hpqsq&)q{J6~JhuIzg%tK^dUW%r8hTKvh8CUQ zY={DpItLH7?18s`0aGX;q1BKPY?UQ(^YhmtLxUw=@t#0{PaHbO&LxYGvd$eHI6T$v zW;TBx=4lEOtx2f|9#pgsVF!LRwczHnjns9|7{^6Ec8{K{^_vMcrJ*8GaRgi#%1VRa zF=i`PGe$snpZ6t$yqyNSY3F@TvS%p8yj&bDN8S*yUFdNqX5F?BCoHH+U5MD74v=n> zi^c=5>2heh_NVe>jI#wrNzzR6a@O{{h|l!Pc*&T*fMr>Og5<9cHS6$0&6+voTP0$P zKfI1h^J+^)MU4$xkhJGU0zva@b@hncY}LEA7ru?~;WsW-_BCT4du6R*f)#&H?%Fll z^Dc%phMkMO!g#^Ey?KHr!(Ah2&OHNo(LA}mStuUf0W z=MC`c1U`E*_oG-b1jGKe_-Pf}L7~V2UWej>hFId?{l`yXfBv{RLH*WQu;&L;-Hk^GnmYVdSo%+&T@q2`48r3PzWOJ9&s4lw%xi6dj`?_cIK?GX zP5w22c7EibF)wzf_*b&bkN}10bkoF$LDQq*`@Ha79#ntaOHsady#|HJIJpBA)5XJ4 z9}I{jPZb3tn5K-?e_;b z0r!RlRS0216t7jQ`qyu($Buj3+uQU@h275o>nPy|VGDW&^05C{hQS%K2uuwj1&VCc z3?7`xQe1!0A*oE+N1S22!t1&=TbJP9mbCHRP;`(Ktp<`jv0`cpS}WzD;e<+Ab>({j z1y@OqlNfodpqY|46)|-pZS7kb*qaC6dxuZg`CLwIX$7iO%I_%AD$H=TVJ9g3uqP>U zxO+)8Zxd-PlZ=*)m=?R-y}UdQL@2u*Lz4p4>~smva+daMTHxG`U))MKP8R)+(OgtU zuy6e9F&$wSVknE_6XEklE^LtUS{`AHYwIafeo)^T9?e9If(Ab)YiEP0!B6UYIBkYO zWCfS2f<+CrvAd_J#pllVqyK@$_|$J;oMQLz%ext??nd(-((DPVW&P&Yej1>tJzNKB zhmHVl%hTj!^%UV=AG7=HkNFEuLw+Y|CXnT#nkS}(n8?ZJwSJAphz>gE=i;lSD)>Pa zi-cVSzmHmZdy__=_dJf~!TX!*%C{-hC}MBNGX$>|`b(%rqejzTB8_@vq8EA;?Gt;& z2)Bf~$sDk(>!W?kwNtQ+cu#n}PPdO#q&C&Lqz7-N)O_n;B{daI0-JZch{~3@$uONg z5f&dI7~8EkjeN{j=W>EhG=~hn2XY9K1W)3Hm6Oc+lg&|hh`@@w&sXcze8wIm16;dL z%GLiu#f*PItdxmFd_@J3$+6__vhsmei!%c6&CejTL}A>r;_C%82Pu~S`%j(fqjvY= zeVHoiIQ%k-!jGb-)za*n6?F4$-2cR{5&*}MM273)n0k($_TzT$hjy;s-4k9}%zJd5 z;lI_}t|=*DV_yuWa@6_}_lMOmo$>Aij3^?6dm4(v{#no(3HS?Wqk^30gXd@k5$zuzX6?5gj4E@=(? zYI7gos~fb z`$&*SqNh;@yF6L?{mznK3o>+u=J`8!(N+u5<3Dj9AtqVkzMY5C+i^w>m!|0QEWbFx zypa@M5$l#%mNPN&-dOwbRS`_PH+7fB*WH_gHUfWQh|lH~#ubmlg!l5zS4S5b2wN^d ztgaPzE7%I9mzfCz2DTMwk)*))y1ql;f?--BhxL_A8`N?00MIHYy^b4!SNouNR5F3D z4iMNijVZ-aD5?M}rOURFUKOcS!tNP}yw zgZ+JWSQt*89w6|p4u)K4F&AgHCcaYLUwi!+U(QXk44C8?Ixd`nLj=pkqpK#PK8%z= zEHN-+VRTbZx$p$n)HAbxKNp+^WTtL*QjUA}-9E<{bNL@&a0jd#0_ey>kwJ~Ue_N$b zec+`d=ZK1g5wEa=J6x(Kn<<43r{L@0Xre2b$oJoc#<^222djh)4$*gS*&@Pu)U5_R zIzX9C@^4BA-f%l_%!^%)N*~X6wX@E$Rd0qTh96Ees0tNa=Br%(4m6b=93qo^ev-9^ z)He}5erZI`W8!Bp*bSm6bWX_iG8WpB58XXOJ<4K_?Tu5*Ws2*CWTBM->@u+~7;(FpKoEgAQ}y38TJC#cgwe6Eiwch-$f3*5s zQ@IGv)%*JCIY;2$-9>t6&uyKO^O|zYOFSjLDfpa4Q~B>p)%A)=gfuP}sqJ7C?DOw+ zT?Bb>dwg&i|4+3n(g8w#&v+sy^b|ui4Qic-OS>m2>9xDu?s30`F$EKm#K|R@Fvt>6 zQ~wlFekKt?^7F9!@G0$zyomVZxJ__k{-KcK(oT?c{`xL3-qc?1=!RJ z%7v~ImZLBp`E0P6{1{gg;qYm;T#fSA0{!Y%$+94q9S-0RL`UbH_7dG#jK%9csZlux zTcC&O+PEK)f}&sT1*dCye#DX=t9_$~!Q)0W3D8GGohL;rGoZzH)LV6^UG*A_cLxlc z!s1YXPmy24l1{zZQ|0ur?IN=U(_`$*=(SGNKL`@iNwe-zx3YH*Fcxq$S(@V2B(dN4 zZb=M>5ZSdk`nY7Zu7$DC81O+Z*9*$C-1s;pscQBWOzvezWsq-Nu5-0Tfh>UkK;yEF zkLaJTFNjEpAAU9Cq!Dt`0`j0t9$i2%Xr?~Q+`gyTFzg4S;yi6MhV2S3K~*G7Cf=T+ zj9K}mi?`BwTN_{17=Z;Vqt?Ho_`_`PgkhYuG7Br|gXSl7ZZkMhKkP@Be?OG5lwkr7 zWvuTld00)u<&+8=CD9_^ySlb2KzM1HZ`S_6#RUEm4=C zzx63fT@CFe?uoC&0Xh@5JI=pM86n~7|7!bD+;X0Lt;gU?WB}$8xitUmo8;ftiOD0X zf-5D^fLqjYZ<3%QnXH{t6~eFJ%GJ;_One-#JtOT)OWhxZ7Z96VYki+J<-uZ(@1LK3 zo6>9WQ939}m{IpOkF}JjNDxR=;%tGl-@Gh}bNyU=u3=uVV|J$@dm-uo0mI3hQ5JhP zn{oEa9Rr?QH;u{cDQrlgs5tE5=MtnL+ zKg(OTH~60`YR+ewEYp|jg;Td&lWwz;d7Kwxw{K`NuVlvbdC@=g>JF}Ph&fiYFO6P@ zNWEWU0_r1goXrKHunVf%kvlt4%$oxCJ55?&kKcNzgrg&zYQdDH<5Su-YOdk`n8k;i z#f83BNTCA<2UAw;v?)A2wxsoGi2A3{sQ!W4VOzVra1B`+sNS!6>f!>+yu|UzG2*aH=ZrrQBt4XaT zy)4g*bX&87^sF_2;Ap~O{ohTa@!%IZs^g|cz_oy%%=iY7)=(LeYXwG4+s#>I9TTNJ zgI2w7pU_CbD+kI`t;#nlhaG85cR!@O61j#K3D)irsilJYzhk{n5M5tuzj8awj*r_C z!C@cQ_XSG-fwxZVswZx}V2g76vTfESi4xfR3m7?h+s7+8zoY{J=b2K;NY73=R3Ft# zPaheb(BR!Oy&{^r$rhZ*Jue>f*#x;^u?b!1ZLYS_O4Rr_uFS0fSY{@<PqRm&!p)&*h ziAbBQO75F|_}*D0Lg= zCeq6v`8mi}i)kjNSISY&Ad94dMOES`LxD{x`D5GN-QCy0j)S6Zg=FFhFS87e+-E+v zpzI_>@$(u^KX%n~ZMNdlq!Uk>@Unj1C^Evp=-Yv$`c9sBNXCXBI|4Zlc55O;DWENQ z0mRI5VQ^uX;vXmO9H$lL63#iOu^2CfbE2F4f*c!CT`uowan69&g43Zs4+#6DWPa9) zhHbnwDK)D?YgRIc)w;vF;E%8qH7r52%`bz68Vr3dDj)t6Ms5Nf@u4kNejKHAOaz_<|> z@g|KPj`yCbgJ9R#8e46)3-M;Y21PDWO2|k8%dJE@V}Fn>5Kz(qoAdW%y@hX*b)L?j zeONoCJzkysEv+~?eqwy9KWp)o=oR-#f5lPfx(9UT-?yelIc&+Sv}>oEWfWW2Ch69H zx@c7v2pDxRa&fvNa<~#OR@m+K(AiYAo`&`Q7*|$l-fV6c)Fb+H!X+lr=7MB!9E^{| zW)>$Jd5cT%s`}-ti`{*STNB^%c>W+8s8(es)VepXzrDq{hG{PO`y6Wh}^1u z-D3&v0TxJeNKg8$G%f>fdoM<`w0rDhviN0=xAV%^u4i_s^RD zDUV?;wZwiMI)1b0o#gCL`q%NSC~xf1aj%R|ktaC(_4(KA!k$bNYIj%W83x~*x|MMl z_t@C>Osl(IfeOZF>a4_If^3HO!wQry_y{-5ynpC&UFO?g%>1xY#tW(CCS1yM&WMjg zbq6Rs>2vh|e3Db9RM&)S@h~pYuZq{!qm8QelW<+Ws}3?uvUFXd2)1>*ZoMFtEL?b9 zQt?3?WB-nR|2k?|;g1jfbKw^es20Zz*gEmrr~b@t67rM#6qB=1l*ZO8E^XN|sH&NSeR&|7Hg&3lCaO8u8}`{?m$Jr)v-UMqu+iV9^N+F749;vpo1%1*&}|)5|a^F1?GMB1G1Wv_Zq&z*p-}DtB13e~Gl zC1=Hbj;VfEuoh5at}5S4)MV0YNY2DXi4zYF)aKp%P8P_(2j@`Mx*`*ZgjRp<>L2JH zC>6c~J*SO!dJJA!Pq|2U_H;U{=J!xw&HKDxQ?f@u#~KzdZWi|zak#`{i3)q9?90a0 zKoFDNox4$(vRhi~R0}J}y|t>!3h-15?Dl&x`|QXq11$cUNaEZyzw76O0W*k+@$1Pj za^$AqQf2xD$7++X#qU^VPu)EBEzH?(bJE7YIMEgcCmtbG2rtigC~K2)4Pix#s4+3c ziP4 z0XABPDHcv)_WHa`cJ_TTgWk~9R26!w$adf6&2Tp~%4(5|6Gi^GuG zh~l^P27(ZwNbhNQtTf7EeeZ@F1CjL6BWjjGkDg~Q7)P%9;1~tC;r_|RzuF8sw(P#?v{GYIaGK!Zuym+Xyqod*A zY7(1Z(d>X%p1;`x2%$cr)n%Tdfm}>esTC*Hs-YDs?w;_{xY&Z7m5#@ZdhlcR18-!F znxX$<)N1{lJ$?>;{lXXP=pncD^)ps`LUvWaZSs_ly(8d`egDA=rVO-&oee)R*(DQ# zMqVcQZg17eo9qVUUR1Ou?%%%*drLQ+UeT1pbX-i=VYeE{8>aWHaWr}`qG6tj&nhHE zCF4C67^~K;*0t7V*hFMP*b<+$&6}}TFDvT+Zc*B&8bx2?YTu=^P*} zF&HpZL~68jNOud;CEYDuV<-(H#;Eh~JLmu6yyDf~@NCb0f3NFvU0)A8tAoC=IH%?f zN<|`q#0wrq*Wa6;1TR9^Q}t`Q_IS_^CVjuYdSiQ(Q6{0msm15MM}6!R*#S4#cz=!} zkzAarKHX_7vRu~ejLUt6KO?xeOD?YeZY?$f{&&nAg(Kfo3*l0&V4LYy!kLU_jTT|S zcm1{2MkyelR?8!IVRG5YC=V+%61@OqWmsq@j;?be353Ln#C93Bj&vMo*e~PPIADHT zo)%m$>?~AnTpNAOYj2(dtJ>~KoliAREwTf_n{9GKGLy`TG;PEOJh6p!@$AlH;#cAO zcn^qp5O{+f$K$E9%+h3KT!dwvk?RA;O%ev=BKUTR#zcG(82$0!=M7=Za}jP{2!Y5qZk`m>B>5MoT* zSe)l{k%~hidKl*~sjc-{*8IbaX}(UNC$c0wH1LX^Z^mi`_vRRqUtE#UVOm`Lkb&lC z?tTA{&BQX72W~Aj#oAMw>`zE0gyZIk)YxI(IB5f?oqG_+3SB2-ixz(M0yR6cN5Y_N;ynRI;5#e4^jrHRd)2#dLW5ibNW3 zbMZE6d4i?HPeO3`_!ARy5%F_lFxSJ`C$2ScEq8_I<+C-Yh<}zYzIa4C|u^+G#_*nA355O0IL7b{lL22`=bImtrX~V@>}GP5+<)S zJ6|2-yWA{YM16xs@^1P?I?q~LdY-fmY4jEx%MKH`=V5Bs+zTE(N@Cd`1}fM*1=5a( zoQs~bRmT{7kvZ|-s1C7Yk>tGYINCB)dE90<7aQ5*)Cf3lduMpC!9r%J8vh~9icuYZ-;HyBmK zc?bya;VSB^E&IFRd!F<-R^4mVpmYk-HRl%}{+xpK)2=ee*BU}Lzt&Tw%QL19ChZ-E zz011**k}U4=$4U4lQ2eXwKQqq}^l6re7Bdn9ms)a2j<*Zzh;k zH!vWQ%t(1v81H7rtd-X-nW-FByE}(y=lMlL&EEjo;%A=J4Sv+ir8B0@Ai8oX$RbER zni$UKP7v47GZcURHh2pP8dM{f9;#mSz0B1uZhM-+Vbe6Jp4&`FX~t3+|GwPTU((LPOVaJWY8b7f}DW+5{Gk1{MYR&qLBv@*^+q@liu@JGU z9#S(?l5H=m%Fpm)12Ja;Nh7IHVfBpJI-TXd;!SYwfYSxdv?=MVI4+#JvU9Yf3S%O9 zn3X7SevKC-YIq}T4YWyYJ@E~Ml zDfK;)TNw)uX<^t8zS!0blcd=)qGjpJ;ks)ce>ap@@lj6Zsw+tho-C=Xg*u*Q6UKrL zc%51_I)261+%W04R1M_vSFN@gx@W1?x|eyqBoIbkCwB45_ja_d9#fcxCmC-JH_djLq~2J zP&N;h5w8;+YINQiJ$B1R+>h~UrDd#Ub)=SdpFa^tFBE zV9*maQdGM%-j9bsv4JD5lNyE9)p{7E#c!5xB6nx7)nCnjZbeMyKmDC{__gRk0H^fB zOG|D#Z-#D$;a9UA8tHZl2Q8L8^yCajEw0^ApMf8Y2!i~}BFT?G$4n#u6k=J`Ac3a`tifmSQU~) z8i~_`KL@yv@4O~3>q@oWJ1&iUy0KH2K7s`|`zp`e^Q+dU?BE6wsTX)o5|Y}<=@P(G zk9Q|R3x}VSnu7N^b$htz8pAI0aO0=VoO7u zlrL_RMbP)y(dNZ@m>vXL#7Lf`EQf+0C5rf;pkicbN5Owre%Mjo81TMSwp?PF_dbYr zNLhDnX+vBri=kPz-x>Sz|89EqjR@ucTAS%0a4_5TJ~dzF&alJqjOJll!_DdYwaI`R zPY_ehyRyLbWK-)NZ4+RL8uR4mZ>!y5dh9*mqSOTAKzKCiV`Uw)F+aiDhJaqH#{x&7 zyhA(oUu8#Et%*i8x&Tc5j?V4(+rZ_L8jD+YbF?j)z%kkZ;-AyoglR6W_MHifGj1F? zX3+6(#q_epw{w+Nte9M0h5!2#q-*5bg|ncx%5$DCRS4%=neZ=3oP_*&Al9TItqb3(2g`V-=e5@+Uo?|0vLm ziRzRbEoU)x!q(e(4@7g?e_y7V$he;8*v-CnBkP} zS9hFUgG3`+g=}k!svbue_1hL{nS>qE9dQm0le6n zit53AcS^o!_X6IYF+DG+yA@eY?1Q$~VpT`Aiy9?^v7Ji0#zHS@cxsp^$Ko$eAK0V| zmBtLM+t_pO5&6pl>4652LRj5t<6foLak;Dq&S%NI$zPb1jkEpm-1t2XvSZMd2 zlshg5KQ3;v6(j8LkAvBVW?+!m^|wl95Sw<|Ih~-*N086($QiMOganmnn8PwX4X^Zr z*!5JuY0SfZoJh)hnz;6)CJ#$f#56snK`Fj>oO4_J?s=0z&;WA4RwWy@=`}tE`BIO! z)}%=h7<9?MiMU$ZPv>|zym-D!1(@wtDBI83;_j?)Je?#ik2Wb6j!2Nwdapxtp|9-H zf)H0`uSk&|ow7WUzUy3nLLAl!ck)<{u(O9OouRGKU1N!KygjT_o=4?*ceI&$ zyiQb*eXkCY(QbK|!L-ZpvL;Lx$SRO~Q`x<*W~u}bXsRcMC8#v3_F&qJz=fHV%EZk8 zcC%AW3HcgMz~OQ~CjiZ|4_jPD$x;VqEV?R>CgWHkLEpt+wX0>ljwgUr2b|Tr-h(-W zqr%RU>9X)@E=Kc%;5v;4&6)))VMdRwh1{rlF|`+}B#)$fcLu(Lk6( zonrHHX*Ugq%6*Nt0cc0mkiy0O{0DrPJpMQNH$$JEG_TDE_e8dw)~^KoxNI-2<&b`J zB$II+`t+q7WyQoid#tSa^fmH{-I)2@N}^h9M@ZWADuHb}eNQUP9%gm1`*{RW=R3sX zIjdWru2-hr{CvL9d1!sgdV@^NL(7^ehK*n(%Pw#b4?mJFrkosT2^6n+oA5@I3!;xH z06mwEkblpQg&1~FaT9&q;~G86}@Ra(%N`RROYzd22@&G?ivIZ8NEE03Bhst zQJ1+kp-E_Arw+oq&6VJeB%Y`^8Cr|GKX2i@<+w8VJWAL3i5umAo{vtyr_#3Ya`WyL zv%ZJ?=;6UhS(R9<4E)kx4d!|>GI;)8{gF=iQ1(P#_G=;Hbn-lQ)dI*#uN{SoHHKMt)!8^PHmy@IRPVc-iZJUJ?I#_mB{N( zPCF^X)r`~VM(WQ;1sQo9VGde5Jp8{Fc#+`lDX!SU!)Gj;OfQy?_7u<986xHu5=^)s zzxkbrQ*jWRUff2W^|LCP^*j{?-si5HxSgYYLW}}gr`J7a&xam@7!0guU{9`}Au}S=^ zf04X5LC!JKQQ~(0*1M>}ppjp#$ImyvK(;6cZ~d(q^@fJuZnRY!!8mO=x~;Hdz0gRF z=Y!bM1nF(4t^e-iu> z^vwO=Kb#_eiyJqRE%wbK%C*oR2&huoFsl0R_k!zQ0DlbND?}~ zyy*?}jej=Ab8B2_p!L^UcRPE39)%(2wHC_$K$Yvua)d^2l)}ELZ)uiPoU^tsz8j!= zKeNqBYjL#61V*61GcB%--=ZeV7cSP_#e?@#VTXyqJJaj%qMad~aL)7VtBVB1X$w?) z_`&-^F-DH_w(SG5#vpo&@mpL@G~GAKc3%Dcg(e%jB9b(nf@EqSNsrgGe1Eyz)Ic(q zQt3oUt=oEAG73UASi9xt%Xbk~6k8O#MHKZHGKh|-G&>?n0b&8_O-S}G+pP|w5t1;$ ztQRY0L#eqWIlx+3H6XwcW>(Icz`;y1J`=8<2A}?zj-ROY+4}G{TgTc$K{{3f(rY%B z+HbhkM9p2T>Jk&=MFP0zE|d3Kx6A%`HkvDDiQOb~iIQxNCKG&zgkikL{`|q?Jnmpk zFroFFDtuq$r3W?u$V99L^V{m`v~PEe-N6HwI9ELF?#296a89XezM^5$Z~^54?S z&W>BBew7A8mBiR)yD+x?>3i_GI^8o`$>c5oKZB$jIlH6(PO?9_N&#h^$W+LU4U2hB zIb!syE0eTsR3$+=Qp+#qo+ADgm$WE>jS#%D&t1Os+T6u=B!4MJ;x|V~$W5E#gD!VI zZ`^P34V2M8EtKAjR8O1a-(Ee$&5qcUxw%OBM~4hRc$03N7`i2_d)l%Gm>wqNd%*jKdUb0OL*NIe-Rk)jI-mD#bus;Qe1c= zO=9@q=m)oAy|*eE4GAQ30Owx8t3WE4z+AMNR`-Y-D)*J&bRP?>X`72|mbQb7{bsTNQNGuwC|mmek4>_YNc)&J~u|MRR!mc9R+_ht(Rc z8IC0|%K0WNoBYz9&AEs5^%u!3<0$d({G(sL@pQqI(H(6o5O z3GjFvDGC_1dasP#lO<69xM{zjJb5x_qpB1+i0d#{m|C(RH9&8_wlz-7ej?*GcW^C9 zwYxBHhW|SOyTR+vaDvfl)7~x|bNbM_=xH9EZV2XNCYIFcJNY-kzSMkqq*$GkN>l<% zi`if0aduUCl5jcGU#wu2i;ZSul^FnV0zS&sk#PFQI8-(|j^_77nJ28MQ}E{bds)t> zEjiXDzAHwrWB*hJCwMDkZ^4M!S!>seQOft-dGi5JZ&d^4w*_`&qP{a!LVjEOKkpu7 zm(o<&w^pr8SttGEZpzAX*LTu|%Sx37DNfbvV@?j+heSy7UbSc@qz9Os>mVu!ylbrG zZ7%n&O}}gW4b_GR+uX^p3W~UZ+_e1!#MYL@{6Z)Tri0*u}dJrj$27 z$DD|j`ScWxP?bhGpqeJ@YilowhWmsH9yECRm#~6Z37OsogQA@XAEInhAgdA9+$g0-7L9cf}0?N^4#wT%g76@gV)(XxI4xgbL?y` zynJ)qrgnaIZbP{jWF$=M1gtT>_IU?sY9p5 zDvsO|oeZm-{&HnX>#Nb)54B_6tW3y!84s7kYV+$KG!>%CCmO95FJ_^v`_xIgQ(a&K zgC%)LkAb_icXVip0~e5EjOXm-xznh&QSdUGXCbPf`>W(lVhN$ZV#-SeW$GN={@1%daeC4`)V4cO|us zR<0vBZHnnK+!MUG^D4vGO(;x(^5wABSbmVF6{%RE1^ofPux}MTg18H8r|_FYG@NLfD^HKxpA<2_jJ(Z!v^40WJ&`h-n)zl&+80- ze89py01c8Kn(fTw>v8rcsGsz(TNRuae~?5EwQA2En*n#%Q?FYXrWtLhUC8i-jVp1`;V6Cm&SYEF$Ee7 zWFfN~gha0{PPhd6KZP0QoERjcZ#XvjG1d1OE8iQ8V!B9^U^Ba2Ic~?lvO9G>!d_&$ zT$GADB0Y=HZq>e2tzk9Y*ATlX+w9{2n7j6~jc9|RHI2zJ%z!e*0rOH!rM?T#<)Cew zGJsNg{`xoYX&Y-GYph1ZgpQ?mrG)j!clS%`6PRIC;9{h5u{ga?m&L5Ee`;j{J;q=0 zC)kJnd_l8)uQ&1?ZRnz#9!nHK?T~;TeGN2lIPOvI@lmL~*Z44qVdcZkV0eBgFQeG{ zs*tfvi$ke;BQ7jl!o~>m1>vq~JDg*H-vTzX-i-lqKxcFxyVs7x<2(1>%`o+-;hS|` zboqI4QL>*n);-p}EaO`su=x0I;G|)*HY460hZ5`q8vlv2z}t*m!`bN_$KR{lUt&#` zXqBAfS~;RuYpMo0{!l)S+^Lu_k?HRaULfj~8ZGg6esa?)DuY-UYK01exX$hcs4@|s z7}t7%w6OI+aN%%aAz^^sD)I7nfrGM?h@hi=T3{*sXs0HA@fCkM)0u9B2bQz;s9 z%Gyt6zOd{>ByBK1(1Z^7YgnshNxAyK@^Vy`3^%00b3Hl~7)G^-0)r)JtU0xKySAV2 zuMQ6?;JYT4r|>m6d``Gh`KfROk6AQKjpadnCMc&G^c^&;T@vJ>uenLAx_j=Kv=X6C zP08fr5_{q+n`H!C?){2)U>miR%7u`oP?OY{M6?Db^CvSOq zxiu(d%~Yc|87*PR_~?V}S$ltd31ST8gZY7;V_D<(5tzg*EPI=Ks zh96qAwnTvQI{aM=pnF2nfPu?V>ke6>il$OwWy}bHTVAPT#y#l2AOl5~EMllP3v|~I?Y#wK zAIaJ>Z!yf}-x8Fe_}uWcYE~L*P&NZ)$@g1@4{=u+SZ}d7y8>wE-Q~ z8VyA!^YS`YG~kMX@9@8PCP78??7`HY#I9y)z6QV-GSiAP3i!+1>}P6xRiry$(uh@h zZ}nsN`Xt`!xu3Kx0R4RZb}DN!t@6|0Pd!M<3KBcI9(h@t*mQ90FlH+*Kx}z^2&IZs zoRgft@&@1!G9F}83?E!h_V+oHNU8$k4Fs?~B1gN|>@RioWb7hSm&eT#1Jz zxbN1~!20JgC$r`b_~}$Q{Oh9GzMK=JvLAPm^ONa;)3@jNG>u)tjh$^<2Zwwuc|{Pk zhyE+xYk2T=h539>&)JfgTYKN)ORFFS(i6IZL-@pS)Yf=zNIpkR#8&~%bZN*dbt~Jdan_|J;oKec zdLvN^`IkKPB<-qr-DvpitBR&Cn=rf`K$EO;dcAYJRr*oEv)_dYT_=~F%l+2>SVlf@ zhe#**&cw_v&@;CJgQ55|UlYw5z&rg^-6*#Dc@x&n;Hm$M7qf1Mdu*4G@zFI>qNU=otmuQ; z9P|j`8&@JylrCI%n@ZNibW_ zc0}LnIzRj1re;gGy_GA_;HcMkY|g$}PJa~&^tK~{#;(c9c2x>)J&fQPemel*7>5am*i6X&a^zI?eXleo5C>*WMqldI8MHYR~FzRNmiszgh=N*Jxc< zo0JK0lC!CEAm(ARpdzD8?WNDgCifta?O*5Lo7O_PwrY>x3jCCrW7>7t!K*^_JQcQI z4j_zfaSwFggTwJaVA+IsCf%Ygb_4OZ$G=Pg`(P3dPh-l!hx54`aDd-MoAg6?wlWqx=LP4MJ zA1F(#W5p=N{8S>M$$f2YZDG0NAHW0zdS;zhu_szDr$ux6RqV>dZ^qHF?}RMc*rIHS z?OX*^e>qg1e1HGNQuKM$XbD^$=P~V*I|rjXph#hmOR7lAExKp+jbG`C^cpoK;k(%eO)j&W6^WA9#i!5DtAyRr6C1) zQExfv&NT%z&>X;j2Fc)h7*6^Le+tc|m=V)i3z?KmHlTo2!|=K6U`Zkd-}a znDv$%YDyKNR>k8s{*1eGcZrBA6jH1LFYC$!c-~;KA{{XOS8AOi2l>*70H7qS3X%bx zo&{Tpj^xBynpi7U$xiryRJ#dBXD zO#HGGLk;TrQXJT($}-SgiC%*l>1j|?^&IRE^c~YyfTisdviy1}radkuHQDqL&~}rb zW9%^mZaMuRt!kW@{$@RxSN&dDa7)Dbe-}2|;G8@#PgX#XbJ_d_|py8%+?0TVK0T6sh3k$N`zG$#4Tm?o)xGu`2L4xL&`gF3SCWf6|D~ z4Px_8-J<{dQI4c)8$1d~1ju0bc-I7?yFNzwSJP{IN)JDlI}?PA$_<(GUVj~$r}TJ5 zUXo{}>$%)q{rz^A(Q5(B4Hao3~3%JD7xu zU9opv{gZRW0$?cJJAO>Vm_f2OOd;+W?@aFkP92mHH_LM~M4{Vgivg@f_ZV!1iZgg5 zo5k#QGfv8{e9Kf2Qfwzj;oXl&!yh>R+v!KFo}y-rF3^DHaGxAKS@c;sia984UwKP<&k)_iWiBVY3lWXf4{Q)8Q%a(j?FvE>e7H&ehQ+L>(G>F%^A zZB0H~3%{J^xVjR4D`5JR8N_;IiQja~HD)ji4jaOQ=or|$LT^JTIP4VGN6`;HWg zCekn9ep2Tu+D((6L1uv;FS0e94*tR%Moy|o^y7g`K4VXmr}Ea;dO<=TFkgN6TNufbmxe=oOMg4YTrMk0Yk>JQPExEym^MAkRnkob34Jf6NTa^7bx z(%wQWly7ODHEteoyT~*hWR12;A8f_>t@Chacxa&~0Nwh}n=D`Vr(XBn^QA9(DMn5* zAIZZXRRl)D@nHh}-QNVt8IRy?%*u3+-~$Pf5@n!=mMUU)F~YOTsNAiHiNo;E`1T@(+c0jD#!9DO`yG`D_DuE&&Ihx`IK(p})Ll?<`p7MO4>!ZOy1WyLC&UQi8>NXF~tEqk=&zuwPa`%-1wnrLw z{W{_M-4Rkl&Sa6zzYI?&0KN312r#ivEtb?kZ*%|>EwnF~#m;YqAyF{Fh;64=ZVSJ> zM!w|NT4;NH+$o3UFgNzjfyB!0`NyQK&L@gQcrQ68AYhID>f)d`*rNgWFoV@l zF7__^x@Cs$VD2|_IB(~&j+bLJ`YpL6ggdm~^j8HD?dPgyyH9(KeCs5ZDB*5UJK1{% z+P;kOW(Z5s`yG(Q#|BW0cl3e|8PP z#zd@}&^)cmd`RK3oNi-W!Zh?mQtXQ@*-Upf4lUn*8 zjVXuD5}WRn-g(eX^z<-DfV~Z8myQpz6!%lk^WON{D24;~;b2HvY^AvJ?&O$&Q{&=j zR?x@Ysc2V8KXFR2qhTVAS-KRes=MXDePG?!X>Z^Wz-&yG$TCO0RPg-QMHO@%g?{M# zx^crt5037lqDmD>7nXfNeoCecdwz$`Lt3c94xKl-EF|r85B-$<9qx=dZPFe!M112@DWTH2fnwcL`Js&eBw1?tV<91zr8w z;y&@?vHQ+r0jFT7Ug44*DWSqCILaKtlW$i1tuTudUDzbzxl(A_`tH-$zE5|V4uhiC zaG%M>`!s%oKWn^Bl&Z0w!~I1U>SysQ8kj_g@Z&T4Zc7R>AksB;_KvR4$YAQS~gbEl5^KD;w&pf z#iw0OMT;PhWCC_vyMnvVl}<)5TDqYJTRI^;n8*x)@e&Zzp!RH%=joS~_jtc^yV*CY znvXh;WXZ4#o2{hM>n!1IocXC*g9Ab^LDSv0mu<&mCGf6EWkv+xP+-8;8Dh0mHwVF_ zBVpVtQM9ZpogNGgoe1VP@uZ|ygJHG9T_@o0AnuG$W9i+&sT#j`YPY{xNDEFk_59q1 z48A8$ZM+yuTo5$DPmLUUfD>$=UuFQN-tGNMu*^RxCLDX7OFsO}{n>ro0IoUn^;y7y z+3;x)F}7W0IZc3s;#b??cAgWzT!9;-xhS%ij6wA2tmB$X| zPY56?VrlQ;JI`^70KE=R0zK)aEQarqG}0Buxv|zyp*%4?J8O_?YbSM!!H4{`0tBkyRN*XwZ$D%7ER|LpibM_&FMAS(F<36o(tIU@^MP+XWZ4mtSzxP}{@p42f!n$6f z*XCxdPqI_}JQD~$mH|ynnQ~-Va@62Ci7=UoM{C@}6hstHGx_qdGj;wEgrutkT~LNh zmyu+znFIxBHIC&s4Vob!-}6>Fe+?}nODDt9CMEaTKf01{3=66j;Jfp!Uio?b@i^&y zauR9<|Myg;{)*?^(P-?jT|ULZ<~5(m9J`@_V`_k1^`OAi)o3+rdo--$+iK_M^Rc=! zHr=yu`Vfiz8q3-Cf&jnKyW)(79AL8~?(@HP?z8V=<<Ex(-}YrO`od4;hk|#KH8*g|{^XHLA}{3v09~39QVEWTK|n zSKl8ePd%<*^cr+06oCA&X*~|^6`Hp)Jd4Dsc?i$#X5J9+NR?y&vi?N+&tFm+(LO>^ z`EybphH=5I<*07#FzLSW-~NKWv>Y4cBUS8#A&ro<+g{dDr@xk+Bp*n9cSMR_?&5!_ zX+@56eG;td8tx#xyTaR#=@Vm^^jZ!sydSFtCUa}O{A=X^*vB1ynU03_KVYB}WIO5KEH}hc0x;7b zv?j1f`|AH%1}3v|oQ1A2HP^LlA^{u=awloo$W#EXl@aKGM{woHrOlG= z@Z*6aSL<_kTjHTypF9n-`_%7*owcmqG=K0uJRGlD(kj0*Bq+$vC_pRVrowx@7jhJI zXM^=SX5Sl?yV;4hdSfk4@Zs=QU71M^gtmsx#6V^M^q)r>w}a7Y(p@1paL(;-uqC>E z`wl6d&ML_)q!R>h+TdbI&YpcFYX5-ruldfZkwzc8!OnLBpMPsR1LsB`=v$}n&;PL4PA!kms<4&J|-N@Xxx4*MZJwy92Bic`K^P15=4wRAC6@Ey_R-lwFTXn zc~Zsuj%dGj-bruJ5S3~WhF@f07hOYNIZzgpCHGLF(AGl(MA3~$5w;wE7Jl9I6vDTm z7aL}CR~9AsR27Qz6vdSpOt8?``mY1|BYu{>mI>mnoezkJK3CAkww~s_Yd2%NPq}UPT7PAv zo-bl%vLlHc;i6|*J~MMtX3`kBt&#eTuy6Gp3P)uxaDFGgZ07bTZH?GEm=_|rSK~Pd z!`0o@Ye5`rL_hwmi~jr(N6TbTcWWX60=&Kt8U#o2waXa_)}gq z*DaTcnlqlyDp+3di0}eMhVaE{IwDfFyzRF@_LPnjnAp?}!mv4Z5YT(F<+aKqt zA*tj-QbQsZl2zk7bNOnfnyU?k0nOOQ>wamS26oB3ufe6fU-c>jtJMFBq4kBcxnXUw z*P*dR!w>7#1@+r@kDnuYYldHH)5H|6OnoD-XRsR->FkZ*AFV(Z)>P$50T$ zU+g8i%B7eE)=3P=6VUxFt_kRo2>qwUpm7tHTs~m$;LjZ{57Hk6daTGSD{EzOUkTC6 zs>D=UnMkeV@-og?PiOyJ?NO*kU2-2F)bie(@UL^_8v(cNciy;D*X41DoA)i-$agLv-5^rv$+IOIPo9kdEEgs<<2v6#R1GEtv*#n?Lcb5g9fd1LDa8(x3M%sORzl6KjTAz0EbX6N>wTASe z_10N47j>amt$ucQ(V5M*gt8mt;&3LD+i!y5f8T@`63(p+byGC_hX(4+2Ad(!YC2%A z-V|<~?I`v`%bJ9mrnGbB)x{Z!A*W4CxOdASy7d85kHz5A%nTT*uPhfkXLi--J0cvh zoE8JA3i`;}ev#&b7!a3b9FDVvR-xsS!7fAgnA+6*N>GLHA$`lI1j1?LU>;VpwT?_eg#;cw<| z1ikaLb=9(&dmDKqnbVJ$GBz>ximNiPCHubVyKZz_E>@v0Are+oueq#>+2TRn1_G(Q zCO58kZdt`t#=Ngx}CqHE` zd82o&Tb!FVP%G=}EuO#Q_YKV)QDP$K!tOv=RRTJO1gk^TMB)^(7lrA#{g`yk=rK5 z!Q!X#7Ug)VNvS;?rn6|JBAC0ECg@LY;xbt8!dc#QM7&dUW3swYu*>@{FZ*iK;}$41 zvHdhR<-AA(qLf5-#rD6&A*xYd+TziXy5=kO@g52Bz@BnG;krB=xn`suIrVV{831o) z))}J;h)-Lw#*xb#2Emurh7>%nwhpJ>C+1uhN`A2`5=}WPf>Sxh*Etm$yUu?Q5rWik zC(iw4R|$9}rY%rQ~c@FF$)-G7QsK}6Bf;8mR%IQCua1Lc{XCh^=|rsPQ`5mU<(Z!iLr0` zpmWIwvi27+*ijwFi)eb>vAEru`QDfwJ!k&2kcVJ)c9Wx6i}V*HWtLaeN#&6u&~fqB z^n5wXH+LW+hU1E(Qg(x2q@kp1A`~w6rPg#^rX^p_ z;m_`&uyvKwaO4Bkq!K_|+5LE%C$$uPQR)5bkZ9YVYFGs;V4PnYI|an{%1uz<~75 z>kay=5y8FFSM!3J3^f`+U33A`UEF5a=SKk=$cz30c++5KN2us+I)$_gljL>EGso%1 zkm-qDeV=m@O@f4RtTV$ht#89Xx2zUs?XZW6z$OgRAH%#2b|y1Cl;R>7y#wK+Du``q z6xe6Bj@v!{3>5qMJD5m`^;G=<$F~J4enItie7mV z0nC5`2ZB&0k+hS`1tNLJsGJ~wB#P&-VZ4W|^CW6ZW2s4uHzTTY_H!#m1pn@VQ{xWZ z#V&spq&VqZG}~cY!mo^I!dhAbQoO*m_1^{WR(%CwYF_b$;S40NNz9Rf;mEhBldIz3YpxyArt`V& zwH)URUCI<8g942z(O%qlm)gSfR@qSkyQ|yeec6)4CcsG>qc<`G<=jlHxlbtpFx_qO zbq--a@KmH)gt1Y|M^?uGVVkC3qoEFw4Lqf^OfJtbdBMqWz1yki-~q&2*3gD06ZtmI`zel2Shwocpahf4pKW6L#U;$_kGeC~{r$-=qiU6qo{e z4D(8*x!TkG)Sf<9m00%Dgl#BM-2q6J?KLT>xfdsVZHZ~%CLi)6Po0pzr&p8>4t9w( z_8RpuuX`-*wwtHk(h9p)rh$*iz&lZN`rj%OJ=}kd(Hi(2@00YGJ2nzuMPz8 z7=@ep0(I8grc0-yc&-t((ZAN_jt};KO%&ewGN24 z>m*c*h7i4g9kc5^A`b~7vz964l#>jb{z6E;mJ{3Z2p(8*SBU+B@uL|CLMgY>Zz}=w z9j=?JEgg)fglrZ@(uLKS__LhH#nYy=`>$`89oH${sn=D7Xk9tTN&8`5?E1!u`>&1^Cg%>OX z`&25(9nY?0Xgukinl^OJJ8>9atl&|Dir_Sb#K!fKag{ z9Ky;|pcQ0rUWUxPCFdT@u{lF>d~GPc*5XjzqDj)~W^eag{P)>JqxbjGQ`G{wu?amB z1j~ZVWRUbE%uCs``@Mfr!Uo>LcM(1lsp-)YpQJP}R<4827ivjE!aV)A<*HeOTJKRj zVz@QD?x;b8&cKAaG0^E{T>=0ad|zH^{%!oQ|M#8LhYhhz!Nb1yshIEH3c5;^WOgwE zReb0Wl^gD`6=BA?hL;t!f4viZ2kQ9r@BTb9n-NBn0gMsnOo)2_w6=f&@I-w)dM#dF zxNM*31t%K9gYfd7D?IP3L{3S_IHE7P=LUJyg(%<8BLl%Tz!n>l;e`wuiQBz#d^J_fw=Vq9(LO*R^bY^bSaPT6fJ?o>dM8gK&T4hUsj6$cU5 zjwCtDop!dTQ;NF7n@*Ct&d*G%quEszJ=8SBm83LWyaMwF9#DYb{oB1 zF1_elRDiJN$j&gX3+m@C_wtIilI(*3esk{A*^6v&)9t1RoHiud^M;p=3M#GVPlSEt zX1uXTRU<^V62Fa1y(B&u#1+RxeGXe@KtDq-PmD-w`?LGYfPA9;4!dyv1Zk6Ye#l=` z`YKzE3N&Jv2WW1@IwIGRq1^v<^3;ilb6I?C_i7gb9Oq+_*0~bSJNaB>H8@lL(?^`Q z*pbZ;hoS{Q?dJafo?WCaijAZNc9$0E?r!>AR$kg}(_Eo*o|CC-FnB~+erq>YxAXIC zCp9m6{3(ft&F64;ONkRKhf}nQ4Pj-6GZ(D&ifYbLOZM?;U0Lf5Af$8 z?BHijk~QbCbfEk4uX@Bc*hY0=ns>Z9{F+omcZV zW&MY&cmIB-n-Q{0A$jEC2)VzhH;}(-%NlN*f2hv0)mOK14Yf6!CgN>+2Ff7F2xcmc zt3EV?mEq_dsVo%<-T<3=h~zHIGO+gMQ}pcsN>h!Eg_79mRLXxS0E!(WANe*t^u&Yy z!L`^D%~Q)rJG8o78LSRt3=S4gS7dAddbFI}kE6w7h5h-n$pD-&B(nH)iwNP)#BS{t z_l)Hnh*v%bP`XUQF_P+Z3jG)X^Fy^N;0cyD9DZL(5Q6=7>Po6N06s2#5evRCzQAqBgNW5qKGNWA0k<%k1fIO}NMFH_Vj z$zq@%ilXHTs42*U%T+h%&M~3*3wuL~nwmAR(!{3K{u~F1;VB!-ir$!113K?M@KQ+VA2s+tKJ@WV1kLkN;bKUWr zz-KhRxpJmxEo)j1Hr(L)Nos)HD?->y;zA&c81& zGMcpAHn8b^N*CYnD7gM5{7%c?T?^Tz++V`GCB~?79*hc5{VJ^L7xQg1=}huRuCGQ$ zi&V_an|Z+lpZ|_S5rtiB-Ol|Sm1hKh9ntl)Q9l0I@K+wvr4&3Aq$dYJ9F6S?Tl$bv zK8)(C;>*x2C=t=awOXN(lO|wojMJ}BQ>M**B zqA#|usY)`vNyoq>`A#o}fuU7E+FG9F7cV*8AMk9eX)DeLyI4`(3G6v46`t;?GJZT} z#J^tuRVWxL=_q=Grpc;weJ$FEmJz@|a{|J!mJ1$aHi7O9;=}*6tPOLXGC6Iu_zjea zN?I-gZo0Zp1U$pmbNjVeS z`k4Az?XD`EsXWk8z##whE-&e12{6)gG~WDJ1*yk+EOiYRax4J z{pro#|Hf<`{Jikk!eeD;YxO#DtC*DvV(HtX1!8e!gf)zRD*_&W;X{Q3ex!91$mS|8 zHxD*vv-H{+*ORu*_Z7=^@v|u1GDKF*X(sMgL;FSG2IRspd6GJBlL7G38qm0AlgtG&n8J7Qown$bV;N7t4uw+h1BF;6vro_qN~t z)E(dp!u|aKbT^9X!qeW(o8$=S#dy2PPTA{!_`q(s{uL7{ddTpi6`bcMaxsouX;ieR zk2)~i$M?Nq0uA;_WpFtS*0>g<4FBZ~*LTBf>&gbM4+zTJBJ>R0t_!vC_@e}x~a&5jsRPGfw*_W1teb)9( zYR!tAHQhmj8DO<{a5yh+SI+t7d+-g>rP`{TmPxC8Qu8~HoWHuuJ6HWJ`>RN#^pE0y;`Tu_x@&fEWa^L;GoUD-cksQ}BHi{-U zZ`bLR&g#Ex?`g+Eg1357i$K04&&btCN3Du2(}d!B={zX=r>^l|h=yKBULJlyDp`PJyR68bmC(8s@5tt6SZcLeQjC@kDFbtd;Lr$hf(8`mO*nD=S8 zYWJra#xX@AQ;Rys_@9&1`e4qw?_6)Gi(0GXMvgl*3nJIQ1UX-&yJF}b>%#MV!zaDT z(OUuDagnSH5ktlekjj~qo~S#Gi+vor&a9_O_4c?JrExh;?u%6hdeM{s_7RyG4f|VF zLL?kt*M2*y;J2l(l~x2sCZkYgUSw3-P)dl_J!~8`Zyl~0(!!O!bAIqLw1r%5=BL`@ zM0W;COxsPp5c{{D0tinEvmgP?w1fi=c++#aLP?tU`0oGH0!+W@gL;4sT9&lxwJ0XUp0; zM=tgnzv9*hT^O@WT5zW&5*8o7;vZ}BdM8#n;TxG^3Uv7I<8`1FQph*GXT0C^I2TbK zuW3kMdEvCV=M&v~+P+Ib7f}9V??{K-?Jm(DKh)&H;IXo<`2B$6s_^wZjsr-|+cdQl z3%7Z%tg+qaXL*xGUs-@7t)k@B(xa$jOM)blk}L2e@xYlFlzF8NQq6m}V3X+s1lRKS zws`dkysN$4Ah=*}7^8|WvWz=F8o}=y;=18l! z`nDzmR`S@mOb-h~X?}|{YL)v3bTUHMBY7Y^31)-6Hmr~>YiQ{qX0l83HyN$xd@=gD zVd^CMnltjW$6ldNvJV^2j{*L@C=wd3Z<%?KfGxDOX~2t~_w%L-a7QMz{Zy~LZxu!k z6MDiL_ozva$z8pD*=9(}vcm%R^-o*=3ynv~|9?F!+=3kOiSv?!@ zrS&XF9BN(pAO~D$`O4ilzo=Yxb#Zx3ua^=qPT5n^-@@G2Lzu3aubo*WU@C{Nh#h~1 z`qHwlho7Q5+wQlL^M}@>{5|ms)zevvM-{sm`pjG$k^T$rJwR)6^WZGKf4df&$e6<{ zTg9ySF;o-2MkSOB?JxDXD63LDy7MwCZnPWS`2Fuv5XJ7*s|q^=m*KrEh50f}`q;}N zf{l&?yI~K-XxIUXZFBabdU%gs3?xGTd(09IO#Yxf5G5&!QQdovAyO7Y(LS_~TY`6F?Uhz|0Zd}umfKR^8_1oS;UPDM$G9jXgk2FfVy zP=&8YHw9G&)l_y4DhNNQ$e!yEGkKGL{GNu|PST+NE%+z|{r4pZ6Sr$e{q{NInK7^f zH zt8eaqa!`IeE@SPE zva6Eg@i>*zXJWg)P-{_GsF4L>!^{mx8cz^I_sfC>_@(g+QEguZM8(ujhh!SZM=aHJ z3e5MfcYcGL_Un!eXw#5H-Zrjue0zy4Lt@8r10rfWo3PIyMJj;0Zyj{f2m%9k_759H zjmIa@$;&7PWaX}(Y#^E9Uu6orhfP-*xE(G%2^66wkx<5=n?oWFpuxhQC|ZMQ16*T{ zT5&G%ie4y4ph+whharW$6}|QOCXdTduVc*#Cgm0l&TDdNlV>+{d#^a#^7deV8o#&Q zhZAuzmO5wl7!~S5^q~898?r@;pevyBh*a_6rX>sW%tzY!{XdqT!XK~oo4u|m=khy0 zp*`?yO#{ruR1e#$Q%DL}=jGpL*Rpx$egB4C&kcQU3lZn<9$&r6A1cK!+_26yRwGx#%2f*Q9lg7|bhI)sabW!W(`@KVw108<1J627U0b`!y}Ej$ zbNb2{!8}tWmFi##Ky@xn(m2obktM@cY+XeROD*36!9=vPaH0RwLkJOkfqcewEw@D< zd-nV7fHbHfn<7s1pjs+({~EoSw$El&**n^?J{y+39QCpxn^M)!rJ6&&6O9k}$7M3Q z+kY&Yt1VDx>j1D^n(wTY;|5@L0)R@%-%yS*iQ15sPm1u)aJU^1Zgx)_`DZh>XM|@n z|B$mltho`-wWOSb%%1!k;;}|Ujz7X8@mYvOFV}M-Eh}M_Z1=`Ugep`dNvMe)_9C58 z`L0sSictA#{esMs=IxOJd!p1@Zy@D zLHAoZ%Ynw@yGgwY@rijaOm_HgGy%ocZ8ec%&=RpbtZ7Di^^?)H8biY zq4#{n%e`71$z`_pP0M&y^P+C8?Hs?^$(honXacnll~7nros~Jv+^jf9U#E_9V!`u% z0z4eOmIOVxCNK+YjgzRsD;6OQDxpb2Us6pbMU@z*$S~pmnD1gBTy8*w!L|89@EJ=s z|npIGxVZM<0^XMU9`KhVS0ry@TKkD#_ zrC|J>dprWvJ9I`%KG@70SbY8petsVB!zR;G^Mi9#BV}Aip=JDJ9H^U3$SX7`Xc3fY z@MKivnX?E@M3Gu_hA&8@aswWI2A%g#heVvP=M^mW5iI9`TfH2?mG00ULe)Zqo4@F@ z?JLjzmHtR))(1@qgx}Wf_prQ=`w?ayycxzU%40M6tMV0Yn}Z%$R04m=r+{qr$^D%| zJr(6`9am}C1UT%muHd|_J~Da^Lk+Yo6F7pt_1+Np@0yyqSo2_^gNpR3Q*PNMGCz&r z0ao`yjN_;EUqfUy^4s19zve=Wy*54Xvv-<_x8okgyyVOB86Rbdrd1Z5wwvIiNla$W zfD5wx)T8++w>bJOkwk&V(DCyKjfh$p8?7D|CF03InYBV0p)0-epW99!UQy%| zq4CFmcAe-M>;4uqbIPxAWPH_XPX@|G;m22;)&%#dn3b``-tQLjpA@>Orw|3Hd>)z3O)E9GSqtyT%^5k$Mku4kd&?}p% zBhA>lye|H#abT&i5}{(?eo6~22U2TV<4-PViS(k{`N!*L$BC6>enV+9*lF<2SfoNH zhdz9CD1)Cn;;@4D4HR z5xFdC70FNi?fw0CtuEgr8fzk%3!;2~%)GDuoX5|95vSVcHUUu0Ir;Fo_}`TTdhg~y z2p(S^F#F#`PZ``?QFiw{5YT3t41aF|y4Cdx;(FD{Jgk_adj+mSQ3*uz#i(x|o=>r6 zx@Xg>JoI52KF|LnEO}&jH*aZjd;wM)Cch}h_aSD!2Qzy4et{ofXUM`u%Jp=oK+9qg zZ;G3t!*RV=Z0xP0LMMC9e)SR6ALr~FJkEjOgQcYJ?74_v%SR%xO50EerUhE*T9P6u zO#eNEul0mLPp$94+o6Po`skI*d+2fw5Gs*Yfl&|Vk|S3TSKJhzoG3;PH$B2?2G)_tOlXAW}^lNMSU4La+cUN0!=p0-vZLEQ=)w~Y+P_@knvRScdfx5`u0VbaX+2*`y*76 zc@@G)42x@+mQii<0F2&SGZrlEvaKou{nLZR%C(5^$XUO&^u1?jJFllx?*L3wD?;_D zb|I^D`!Ego#v08Eu9XjXAKR{m0^4zWp9en6HZd0t<21|={&KJr)AwRa<`kVgxM}{61rz*_lJqzFue(yE>b$b6(Lj!BX?FT`E)pb7QZINqG%&= zyOP0f0UJY{EU}3(bh=O@YZWCv8Mju5w#MG8ri|OFVJev!IiFM=x4Je0QQ|y6mNd!GV51PZaUeu|d_v3&AGjXN!!&%VZEd`A4PcC0M$F6QuhH8||!ZdO7b5xxo z$TKXXF>DzFWUH99EXe|>vVqg2{-s}NIgMYD`eNOu<07?86e*5orYnHb=?;!P0@yI8 zNYqbSMbrPz@@!+SSHu@h$TE4Jlqk#7xXX6t*nt^<1&|2>wkv`FG)S3@^7}VEzzXg6 zsN6*+#Y-?mdpVbU0ADW7zm}J6(eb;@wmvz= zZaVII(B@>H{7(xALjXO53}O`5z9Y6Eq#|$y%;y1HGKl37;M-{4jj%Rv({GSiRx{m< zXL7hEc6)K9WQBo4g%s)Lno%U^kT2W<1!4#L(ZearEOe3IqQNC)k#5!SiJ#N((vWDFFYgHL}>87wLCEDH4qL?i~mE!?_GWue#w+(Lb57eGG0hrsqA|{?5?(m|4 z2++ivSU(~H{j0Hxe}@IUdIaRti_6y;?BecoQxtt`y*ZPd1~I7nKsuXM5bsvq9*z)w z39H$;q`+*f=>dt5dRfJ_IJYN;1fr^Zd>oQbG24lRix|Ef;NZMT0+QRyY!bSGTUy)% zbp}3WCsU&k+yVu^z6&9PggpCi)f0yKv%q<)Cg#r1menXUu;x5BV45)F%;fAlg(0(B zFAjD-cUyc_&E_hsa6Pe(WCSA+HNA(}D!s=CHpaAFKoATQhI+OjT(_wC9s=rna=$%o za)G`~ug-!X(m|X@sw^zr7Z_hG^H^L;!7siFSPZ79FHp#fGkp*b?A}M;W*0uQ8KpBZ z9!WrQJe0ivs*gRuJqW7_kLw+pt(CgzR-~)V_OEO$ccJwFG9(Q16mS>dSGzsMbe6f{ z1ta{ia{yr(KbCx1O{B%~?d?wx|5odRXMn8f#oZv1%HVaYu8D8=%Oe_Mq&?4(lewcL zPuUf)Z(FQ?&z9&ivA%Esop$ndWGZNodQi)Hx~Fxp&_}>;>*=O$JwvH$e4HBq0aG_X zLe22lxi;QB1I=5u?VdDLA)Z8lPMG}_9G%O`Yxh+IX->%Jv5u-sL4m7nC zCi_FcX0wbGa2|!Fxjn)PZwht6(xBe`N<V|dTHSgaT}*e?+R7C^I;C-=mP zfMpJg(mxvWSddY6rU2Na3|*v#!oXem$rO#mp8vWU8Qy$ANLuri;3{x0)`yVSXhkbo z6OvVL2tvKW^Z;$=mixSc@e#kv7X&G%U*0&jaxw@;RQgtzBa^Y=a{oSr&{<}wQ1r;6 z*cB{in?Qg2f!PPIMPn4-5E->2Ow>y^wMqCejCs?TOM3Qqd+u~|-YM9zciY=FH$maX{<D@f7lA>_DppBP}V}FzTXa;*~l^Iid{3)!}}XO%U#3ZGUY8m;|uQ2NW=Gc94Z3gB?En180KwP zYHgTpKl66=ttyBCFmVM&(FIlNeJ}k^>pMl9@*EC(M~{tox3~DQdMV2L)2Cv_VOiHJ z>$WSk6_h^PB=KDn6ipFsElfRJL_8&(2Qx&9Xsrxk`EVHftIz!B|3+Go!qsC>4F!FE z0?V&ub!m=U>1Y#L!|QS?pJoHEZt9>ObL|!d7pV3|;DiMkKkyk3_E1&xYP^Dxb2KL% zAh?tJ3yIkc)upc?13i{=zeJJQ`!aWK&V2FONl87{Tfa{$%wGK(@G^}iRM}iA`x5}+ zukZL`tuckQVUFgQ3c@xGKPu$rK8-+SMy3U-tST)VK^Y9OM{F(rz(4d!k#jnk>@-Si z;?OSQW`tbitS$7+6<#l5sXIYZReP5;IddK$R)sb%2>FonL7}xR#T7Ao{i9esB8W7S znTx@pSfNevs?I}A5r^Vk$#oncRZB2+cGQV2(T29-JxaOw7Qo`}tVzmVc^9q=%Ub5G zzo6(BF#2Z$P}UZEo+N0J}kR4b>aH~S}qD&ooG z1)X`@XqJ;1b$YkW|MY9pXm=C3^CY*|Fl)(vA#k6%gh7l2WgHM1kRA|0vONUSJFCOC z*<(T1QP$-m9&sKQ)p-3#iO5ro9KriSBo&)&FKH^ZS!RdA6ces1lQ=GbeNia`c{|)h zD*j~2)T|$lWlr|V=lg2qWtw^X8k86@1B3z*d z0U<4s0?KLpFkv6;FVGj~e=S!S@>F8W z>_YtzEzT_#fr@ZgZFvM_YztjbMz-L-7WEv3@Mk^mlMa&*f|sp*TL$MlO&35fL8uK* z9GTbt?@#pPu~~{W!?o7idU;)_6(Eg13?qTtUngedmPfF657L9Xi^PBxZ^gYW46qaY zVLK^nUisc<%8xF{Q&>H)*#u|`>*3{n36HY~J50afk}8Yi=vB50_eeJaSo~5HcZNlV zZ_D`Dz{6@wqwMW@;;M<&5LVTFfiSYY@l#2gyqr$s(?rI1c$%2vx2{!-gml`{?RFK} ziXXXfm1T^XCe0NEnRt3N*+$bvZvEZ+s`sr#1V56IVK#n%$;-$~sXtb;n?|haIQ~_u z@?8O6?WoF#Otd)A8_Wx1+yR^!IGdvOm-tE-H@y$JfY_m^Ky;#iN+#qht7+(IhndSD zRiJMw_oXeqQzhyn9m%E=c6RMe5e0g=HcQ+9@14bA;3wh9+WS4v=*Xo;$mZVA_{N}g^RbUSXIM>MF&q(U-41`n7#Ju z>uhx9*{58uBRjf*w=<4AM-XONQY>kAfgDKVs{2pCB-3*7xBU8bdN7O&JLYzzVe9+D z1~|Hg@hn@w#3_=eWN*iFOl?H;9x%haU#eU1D4^@SCc#iOl0oV4Y3bBD`EFbN$+2m& zpfd5f4{+_8ZIO4o37&jg0+oGk+1z!49|ydgc22$?8LeuSI{>*D-pED!+P8c$u7^Wg z9+OtXkFLJiw~)bwhQ0HPhLcnSHb{G^0VS4*W6Q(pbdJErdb?P-kIVnE2&fMXfY0z> zi70a0{;5BTrkBlV1ip8H7f^UaLS?9iWR$pi%=p#0*2Lcl7K}o8uUyG@1OHH8BHP?3ToWG>8?% zEiBG6cL+&T^~-*}i80`_F==pPAtDW&7*mjrKfrt17!I>9YlCv7Z~E50Bz7ZjN7?@* zI}btbtF^O-r}=k3K^zQpemprM8e~h9lkqb%`MDsfL;e1#-6~X(me11Ouza;|lK+M8 z=<{U$rtP~13RZFQJ09JJ=>zy>>(O-}K7^UjU8~dBg7COJhR3PR8aQb?ZR|sX(*}1B6$X}Ou-s$Vf@vv?8k!_!fMfuv(ufP zfspD^VU|WUsFs%1dT*Ag#pckNxT?Iew84dOv8&e|kYi4#^}^|+tVp2gz?J1HFc@JP z4iObQb$>hfJ)$T)&E6s}u!^ZhJ3VOa)^y-01atRy-x#GUnRzgvaBDWNR;KuQ##*UC za+{{1iKsgjA&0j+=HF$)A&TDEnj+&a`v+N`q>2OoJPxMtUC0b0!&ME>p`mDIE1qh4 zVhItKIW8tgf#rZ@2YdKVtuq`Wl^^!Y)4K?ze*HhG&2=e;vhd55xHr-(en_$$f|K4!S%q{5>F=W<6H)} zrMV69I|A{~gdkt0tV#r!8`KkR$j7)(o*S~tZDTY07 zc#4lj%x{kXv~6X%IDCBUBgv~7!J!NX*O9k`VS)0tgP_HQi=%Bu*CHI1)<|nKz91EVj-!Mc9XC!{cqg#jaLg;gA=XhuxnyK5tx9 zy5kg!6E_-TrM0c}>CEzWqdc#o_I^H71QHE)8F z|BAnY^)7hI4tR3|K65GojirjDYfqO;=6uvJ^~+d@qtz*M++9VIl)}xT_DLr&m)f_0 z@RPlRk-0@*RxJUEdDfQ8JhrYUdWEY_DPT-D!XnkNDzmHv@UvoqY9a{_aVNTGeZT~Q zp~A>EsbG=EAPS@9N^6-Q3R_TXi#JNge5hO!vM|ey;?`RAzYbJw?fysj)<9)8@zVh!EMowZ_%T#u#ij9s;8$uMs*$n_DTxgGk>1K&H4jU4Y{N4z{#@*@ls_n}kt6 z3|XS#NI92p^q=}wEq>^nl5*2-ILk6qWyzA9ei7-qU}8UrMZ{%ScX`5t zN8C6Jxkr>_-W+)cTmx*{RJq#NDFa;wKZYWNitAC|qt!&|YD-_-7OE^pe7Hn6O=gPq zg9_7CkwN{W7S|uos|T)^<6=*tA+#qP=Hl|Vn$E}HTt+MCifWG zn$n+&B^(*;8Mh9=eOluMt`u-R1#8sfc~F6fu-C%BV4Z5DKZR9Jz0nrxBDLi`Wd?hP=N zVn@mI$baGn!eJt|Uzf%JM@X1rT#J=0%&^-vvLOQ6+D>C>0YfFBrh!nkO4DZRcDT z<}A@(mBcKdB>ncAQ1Tv1d-eA^Uy5?9VTFh(M)LIS+0eo~X75*Yhf&V{0zKi#f*)RS zt#KYN4MTZm=}i&x92-`u^jVb;$!YO7=cSr;XMm0|#&z7PnaI1K-uMZq1h@pMY>zh? z@cb98a&lc{TzqbP3-^*d!)S!`%sPxuhp%uo5_B-uV?l%-5M!t!c~`Z5z+x+_E7bvz z_md(oJpImEuILT9{_{%+nmpOW_8mI8(PY=7$X>qQ#CBsBUNZ>^CM`qb#oyuEnC z*0ArS-!ZBCjG^d~(_=u6C8qY0>)L_Gq7MZLh6D=BH(n6i3v8&=I{o+53$a=)tIJUi z_xMD-u6$`XkKG?D>&enEd1?BhMU4W;{dfuYK~)qQ77b7H5GtlC`Ptl(I~_j9e}g)L zYNsoa8g9E?ru22e^BEtQ*vFVRKRNF{Oz^mQj6M^7Y)_j8AX%~w3bUf3`6VSKFya&` zV)Ci(E`PgP5@M5&k(e(AH20>;VSIL^J&4mqNBNq6=uIjC8{|4ZKw%0b;(Fxd;zv(t zE~}je8`?~w?};~Bf%byMspxB@wquw?3eDCgA<}q^DxgK2wLph|-JCZ@nJ;bXE^q)wL8z$SSSWwul)op9!u34NjP+Q=hv4nB2bF5&pHyD;o~jq$ zZAYk&s3K0qGTOw`gIX1}-IrPC)+VwJUbgo7M`M4*i%#6d-hoV9UkGWNRbDjlxMPT@ zVkaEsb0q7ztg&p6cs~)a{h?(5%vrsqtRr2=E8RsF&km;@ieVEy7HJ|~C1Ms9Td(Qu zY01zbMJ(jrjD(dhpGhuzp~Z+|$bGbay~16Bs!oUn!Y;2-G`kauP-_^1J2jVOXg*HG zJdiqnDtf45JsZvS)NPvNTuM8~Mj{5ia>kg?csME`peWQkCzGVuRPMprOsgmMoqQWmV^=Lc(B*t<%)s~7kr~>bO>?{<0>i-dWmdlK(>0ZX5ZocJgiM07 zwVi@=eBK3D8TII*)^?Aj?k%hl$m6Dz zFD*RwQ#1ACCqfS1$t=^@E8O9L*_~5`iab^cStBRQhe6A1X1rrZ z7IJK=jz|9@zX!dsi>{M{NWX@`|Lcd3P^|dg*+p!O%4}-*eS^GR8`Hy z5VhVc5pGZQl3as}#O{j=+QpoI4O)+FEwr*B3BxIHh}hh-<)Kj!TIn#7sV3>eVQf64 z(|=dwoo`b0gNGHH2>sJ$CyH>_D&i(VvhAnc6=}ZD;WtIa!^_HP>~%|~+u#+!Ap0(T zpnd11no4U!#g1g=O@g8fIkY`-vPk`-zMB(Jp>>{p6qt8YJjFn7MW1<*T%HJiH9Hvc zYADF+FIl_w$iUe^VGEE7_1C>SLn?>kn~Vmxu!)`_?PyFQ*7! zH>^SHeHBxKs$XnEI_nSRPy*Lo==I)hJ9ZpFajNe_$@cFrM=w|OK*Oi=+et(!zG~bH z$Qruz-#%JS!ltjWsz-m> z1}j~YyA_MixY*PceLs0z;J*vQX(h@eaLm9b77EMlgw+48KWZ9_a0HgBUE&Z0%rLgL z90QR;U()WHU~T3J$D~af+f z;#BF9?2|?$Cl)I%k__5JR>NyAs%sSZL-aPeCKNX(m>MrZ2TxgSk~OH|VYF2|43?NAlWsY6PMx)VTG>}S@u2W(@O9e)^leoDyi94Kx^(k7<$ zSif~{Sc6bzu%E0RgUwPg`Jzd9kHN$Byh$!!xt6gMn|&3a*XzcW>u5XLpcCu}Kq&y!x|`PX2ffAhUHbEB_mH(}+>=8D#ht1W1#Y~uWylg-y{ z7j=4^ya7$c8zs(h_(;bub=!TbF;qG;M41o$f?G9}Wg?vI$0Du5bsvFAzYkQsEk2R$ zf?H>l=YG)K==WjaZ>>z9+x4YUE)bvl-k=zi!^G7cR91C(yO!g1K+=D4I8eOG6c-Io z^tj1zcWihk3y6QsYYY9XB;-wm?}q~0{0iE|_k(z6qNs6*0r#+4!Wd_0&gFfgmd(LcV!Z@DQ&{=Q#O;uY z^%`k)U5~H%{`9J!$rPc73%6fT9xV#o6lcfnaDeD{Y(uLUM-~$OhR}G=j^M^Ve#ULJ zH9s69?De|aW0vUxHU!i@GbJ%UE{SD6ql8Ar7hOgm^Yy)PUR!+eeZ}8-==l{@EUh7Q zmSt<1aFhDTYRjwBWy;3Db=uX{6%E7&a}#IoefR`8HK$Mgr6KPR zvLh{7`&O$5I30T)OhPe-e(uCl0Iv-*>K$!kgrzQhSla0m{pY7M*V2g5ur;ta0h{J^ z`CCmm0vt$He`dPiF=(%r;htVkP2V!DnXcLOA7*mvjVUt&w(^I;%WSgIAddRY!yiwb z;$?vn52Imhj5>l&bM`e7V7kE+X+|*+x-T~0j~6nO@4a#k#>!c#ipq2?3T2`BUZd~2 zZ~ty+8o$;l`*S(qFJ6166fV4K7{K%0+%i*U4s{y*0)-52p|YMHhS#9E07=^n^s&SMdOH$wxjhyh915);o?0cGa{)FydS9gH~kT?MGe0vQ(MRx=!|gQF!O^3QIXgY z9B~l;IxY&;r9!S27AN~aH)qbK1n0wVA6#ZAN0)jkK0_I`Uw|={} zTjbnN-C-E25emG3_H|cfd*9d>AcYW-OY3YG~0%FeZJN9-s@QV z4~E)3p2&ORokg&+8ySelc3Z4vf72=vLKGBx46LsRpt9{jy{_JZ4a~j*=&r0S`!Tqy zZLC}=FX&#G^!%3Ur8a*LvR-51WzIx?5fzKfDv!Yr+6Ay;@e)zx_^MHR%k$Nbx=KE_ zGZY!#`CTe(Yf~QOiBtH|V;a87Z_V;%%}J(6r$IW&23IB3KL@h#eK~D#2$g>NbJ;ZT z9(!tpHK}Pu>a`uj<^Dwy5@50mhmHxMql8>^PDxQ{8aPTk*$~aTDR8&Q(VZ+^&Y9R= z-&z5oVi}miNoj|RaoKCjizjFX0tM`6`t>a_7iDl}Jnpl0Qjjo|DAs56vqyFmwOw*^ z-u9!#k-~CIaq;~LTdQKSe_z1QC^|kdM*j`lA*_@$(9jeUhUXX^_xQ^K=6C&J;^z0* z@w=PB44YA*42=Am*5=a5;)KCiILC!`sgGxi_1ounqJMEmTAH-r240I6nP&^P&XYP` zWI^~ighDp98PC^fJ*XL08|8?cb6A6_mTo5ff_$fqq!UH%HxmDynURMdrR=&Tc(i46 zUfeGGj<1~#vY@A@^+I_sw53-zK>t{Gce)?(rXD`wYoUIYK8AhRM4EiXhS|qc#E*<^ zjFw_#EjD0W^#c`(4D55xjoajBWuu4rkmQ>$j@2!?Ck#cX2@iMcuLOMl`G;nn^!1;3 z=J`{d9?j1d<*L2|*4cp#$amqobL-=?;*P8sjtD=0Ic>9+P``$N>FLfKaW7 zkLMLm+|2^7C`EkN59H>O3%r^*&QJwD{fMRtsb!oa3V7Ud4RY^N@c(XffM#-1n22>BB`WXNFb#rOU3Bdz{&B zQ*D&cj(vB(ro#1lAVwDPu-_aAspEwbmKF$|hBa41w9Nh}n$iaj)0})b9gc`asY(rWrpNI?;5?dFuZ&#`kNGKy)kLV z6ta}G8_VhWmiF-XFH=+>7~2-4=TC%;4t_c~Pzu zsRB7L=2{y$B&qx+ZtrTFy8Nj#PTfI~|NHZ}Sa~MTNwW1A;w&2G$a;z|xi~l@t1b8S z?MsFj2JHMgEs_ukegfkrp1>YDQ^!kwoSj8W!Pc9Xt~}CvlMSjNXQOu)T$1<`6m2;W z8m`+mI8%`cX&XlLqmF>GF9m&ulouYQO(kt2Dh zG^`=*HV!72DwV=5s`>t0Q0CP3G%Ykqkq4?xAo{5ldA(;%Ml2OJ?rq*GfL0CB)^?x3AdKBOc~*fFD*`>+6`j+$lx8iY+^xyZMZ{ zkO)zSu8M6?S$DA*UN7-gY@5NxY zi_OvElTzQA911Zs{POj!)uPjklJa9nEMtYLfHC6tr+_N4nd)l$5dubDwx-HH2KzV0 zvf`ap7oK}@_V@Qzp1K^nHDb=gGk#OPCjl}#@75-!!cr7kZ{V)qW>a28i;}sFD}#fT zd!w8h5>P;?_O%}DAaefGUm!yF<=S3&QE5Zoj!1cd!ABk8a`JAA8G%fz-Ic)PR!R>$ z8{WAAr4%8Q3YB!np8}VavTy8S3u|D=X1G~zKD1#BSE-;9;*fbuDceMA-5>jhnG(!a zAI)aly>L#);p-G$Dk=ZN$g_Tr2-W_6WcM1D6R|NSZ7!Sh;?ZYPyEB9dlZbujn56#H zHVTR)l<|7qtW);G5p?h4(YvW)(Aoe}A*}92cO=Hrs{sOhHnyAX`xAo=58zhG4nku% ziI}t1E|V4!C1sb|NbK4yBP+`6c-N6W^~jutzhK^9%Gm&8qCe!M+FA_5A;28{`rgFi z&4hh?XZsa(!QeF zzM^s<|3Dp)s`>$(Zx4|}4BYMSsDaE}gQyN^XRIxX`}eK)l%b|r%F){Hd2^&MGIZeY zSt1gKKzbH`c?R#q7n}aD@F~u%}9q(tHLdL=i2tf?@HaT`-sAxbJve+(Ign zTJ&a|_vxnxdlrK2`y~hRAvik}MPc&k3q-u=o49<45eOS&8O?kS3NDR6E{F8E4vjzb zHL{V_W2Z$XTLFqLag~C)3cajkhCK*1TIi$`D1F> zvu{p>4u|b;jgULn8lB#j>q|*sWFecX`Ahn3OdIV3|yyE_+qN}E0(2Ul2sat))XhHd}2b|X)C zZhV*iidux?pAk1f&z7N2soanHSAx%!iV(n^oQ$MQqdNY15n6;$1F6}jNa?T+oWf8q zRop?+NsPiU8Pa-c;7~cAM~-LT+)nOTe6tVS(IxZQwm0zD&>{Z8XL$sE&9|$hAWFt- z{NXqJ)Lh}+zeIRurYC2i1<#DpKfaZY*tJBr1@-W#3B^%fgU2r9ZT=ARH50*~izzmx zuXOEoY8=}!|MI0H51+w>>r~!EgysZ6GN0#GN86b-t~%#11Aiz)L*R_UriGCBjuc5U zT^wB;l-Hm|$ZWH#U-jd|{M)m53Cyy33{V+N{nC8%dHtV_Nk`N>@{^f2q4FdS6B^R7 z)sQ5N7owe;eIbKl;z1}%pVtzD(suEvp*e*;)qx0qCtPsW=0B@78K+!fvy13loJJZ- z1rdceo@G+8tj&nknXaYTd8?p@VuGLVndB}c7X@rewH>P-wzSUa6%C#K8@)E(M9cOj zd}-P{syZ>3_#@#-n=Uhk;EY@!KT!V<}9wt*vS^Z`)iyH)s~arYcfnjX9Mn z(a`#YYMW~+)4`BwCg{eB1MXyLw~IFg+biSCywXXN&YPA6f81Uc7kO2X>8+xa1Co&v zV2Be~C0gAG@%s{gxbgPSN!VVFkKgFl$1W)q(OJihcoQV8pEC1aM?PWB3SEP0yy2;K zUu-UR^l4r=TeIEPzNi662=~|@J9B$u`_Ud78|hIPuv$OtIweN=Ob^eBREN`N?yS1RlrgvW%Nl4!!kNnTvUwjZHFb$(@CoR8xin!D z<69ZnF%+4)D?O+CG8e83>Y78)5J}qYHrStB4vZxYFbgbb9Saw%l3U=cfR4%+r>-D? zWK8;!5PFwH8NXud(!Ssp?FT!!Ob=pr2pvmT}Pu;2NUJ=Gnci_W7w z%Y{$%6Kx!=EjOLCaWBgLnY+jSuUs$}=yj2nfvQaSKmk8K;~cqO-W$Uh3(C;+T!Nyw z@7e@+bW2H`4fVWWh3G`i1D8^@GK1;kryI zL^8h!w+AzZttISTD%;i$#yyun>dx5Dv$byfz}k0#YyVdxgR2!qW}ez1qlct^ZCm_` z2zNoijd2$ToBKEy2dWMml`6Bv8>gSCRnoqvVT1Cj4ACJER3HnUrB#KFaG4mUvR^WW(<`MS}iiynjDjCkxe%T&=L1HV?y2uTPhUn zUOS+dfhbvVL6?g@lg}xW$9F472s&n#p!9_*MJV7GqHpFv6}S#adatcUKcS-=>9Z9y z@f|xY``!lQL)H&jaJSUx8d+VHaz#X+BmEjz)})P@ti>A0+e2$5fscJ8vBR@XiMt<9 zNQEc(B8e4+@+j2$G&MXPH&y++kQ)1(p1T7WeJRBOFJ+xA>CN()oaf{l`L5gN7M8Za zXhyi7rvtrBcg&~l##Ug<1!D}QbF+KZuOx3H{;PuBQM(HRYY z_!;-#aio+UbFL#xw1-TY{k%fvU7~P@8Ew*&>o=>;y1!lmHn9tUY$u9!^g(e;hB!W~ zxK3l|S|c^p;QJ}_D89weU8THrus3qSpHIK!Wi~9EV#P%9R48CCJzt^y8g;!BvgQ*9Vk6cur5OW7kwpEE$CpRrRVg427}1wf%qMeAJAeLi znGA#FOV27A(xdmd*SHRj28)WW&eQz9N#36L-bTE_n|ZAqXOj-xqk|Lczy zQZb?oGMyw%;azLPu0f8w(V*%*dn~Oq1D>nE^bv3Exe-MWt>3~|dB;O)5dHm+o1r$Y zJbcTHIK>Zt`M+aI8!%UWym{unw}F(sIQmKaJH8-xcQWrmo&37_GD~7}oT_(!E5e~@ z6}g+x8b4cN#y>5)T0pyyrDK-nlTMA-)hQYj2#K#&^=@_#Ba(6=Bg!e>{c1W)-#SdJuZHfnl;mo_UdL0a3|R>?id}u#H%r8i&x-wwSte!o^h-$?#DO}3^N7ok@5`m| zxNp$^q3W#vn*86lKT4!TML@bmauU*wN_W>lDUlM89H}7PARsZ2A>A#b1*CCwH^}H1 zJ@{U{ANS{n`@vr@u6iBE@jT8`IOs(>yj9{n{oNOQ5oo;pqtMHvE9Q74@5g;3 zCp$rGMW3~^SPB}cdmA$+%N7Z$OB#~yaTt{@mn1XS(lTMAO&W*^zJjglIh$q!-fcVr@T>}0+50kO zKE=PvLBL_C?Rv0nPDQwVY;N;s+A;k=VArS1vEFLv&%W&@r$y{%kSs4}rTRyjJaLSN z%{6@rHoqI#7w*fT8ehdYk6}rCH(j+O=~96%y454 z<55^$pFMam6#!epm?9Q_-k4T@3eGRo_yE&sxeZ>)H5?XMiP0Rv<~8`b;Ig?J)q zgFkO-sg}^!EmwOa3N5w2Aw}+6d0&i|JtzHzZDPcE{JnZ>*BvL=t!U%*_qL=Z7{gM& znX&xx*rg^`Wit$%<&xDV5i}y|!$l7G< z12Uvtg^k9+ZK@38kUyas~@iuA|@#rC-pJ~1O=mAms@FNy76rvz zJG`QJOH}oct(=yAnFr~Gl6d8-kRdozrVR`JwaEyI&FI4x&bw2coIkK5zD zwxByB<-X1z&krm=TQM$v90zqKF z74_`aBj?w9r}Zix>jTBLLuI($=T{}IP_LuF7yYc;I7wNMk$&~!s3*m9Co4#pd^ta?IjCBC`TDd zR={54k`54^MrVK4y;o^I)^j`OYJPng1YOk_on@tD#vBa@@86DEPh&zUinpA z7NzT~?Oc&x`4;l&@4t5j>t2#C*+Ka{lX);!7Y~7jp>;QJ8OJ|7*d~;14g;xN*n!E; zZr2lOw)Z+eA^4Ogv+9EHFG@9Mee>>My+B2*3aCP^OA8>BF!V;Zy$aREuD46lg&Sw0 zcOM%^%9b4PcihL70(Uw>S1c@K^m*+)tS#Mq8C0>kdPQmaB*omt4$jwP`)U?0cRx_B zAH74qUDB$rwN+N{eyd3EB*5DLOA6#VF;#TEeBC5ihVHs)gijMuXf3v}~+Q%rQExF(&rEii4tw`LN zo8b`iFs$+tNg-7vpnHNf0^D)|@ybf|KP#${vHbsOfvh_%Fd{JHuunG_sN*~4L3VrH z#GKam=7@}62*lX;$k{`eF2zs+#IcSjC7f3EwntN_AX!M(e>enDD71aPL4=r`bgi5G zS)cT_Q#wkhi8L?>OVX*AT$IXMAA7klsxHZMj$tDB_+(*9k>KvQTj7nZws5+i@zHh~ zAv9}&dFKmh9ok`;t(%_y2!}%JwPsW^K=98@mG8EHFj)J#ep!`Yl|^oZn3hXbbJJTR z{8~QQ$t79Oh905dG5j4qKF}73jg#+0JnS0r^)jH?2YoGih}1j{J)^y!XFa>3Xc~5) zD7r><;;T6xezsTbgcoxP7Sh})Etpdj!=hgze6bP`o|$nZqobT%fTHyf*%x2rk>L!A zbyYNzVTe6xa%B6<%H)QuF~9Yf{rqg$SveJVtX}VNNtF1(a=ofgcXxcV?goLGrJp*y zk^|n?;}*`Cq18O~(OwRCk(_MivxjKxzmQ6q$dT_cT+ZbpM_UQrzO-F3OAYF78nLPF z^$-||YQYdsoj1NZxK}7xn55%+e!8}snreRO%z$ZTgaqu4d%0GwM-&Ht>z1askF3IG zm6oGPcuid{eaZ-zbsYb}ZRS6r^+rP8cf zXxwgia&#@6OT7GguFuZa$$JLife(ob4Td{abjl>bF>6HeLIt>z!g7IM`=YJ>2;i7v zmVOX%k?+T?#MKS=7;t%LX5ozK2hqVojU2YrzjJ9Xb&TD>QH=^`ds+r(-fu7zQRMs8 zFZ~VVhG{o5E6E(Y04Mo0lX5bZXhZfeaBXPkq-oJUiWS)x4DI`;Z{sA)e2KLGRbypCR+b*nA%SD$qg zm9jm|AeJ4h%ih157hb>Fm8s}ZEX2R~kVfR={>LZt_zuxsse#PwsZV$IYF5=<{{D;X z=+16-5SF)7G@b3M>~y{MTOZetH8A%soHF8*vF(?`RG-NLOEgE-d1&m1c8eVabitQfIm;L?%1Dt6F8a@3f>=`t@!&T0DZ?atbRukcD;3o!ep2wgN=ZF!L@!r#r8EqkN z7giD_N@bOz{K4=Pq5LXjO~^r;kVQy@u4|fgho7|1`N8?225s zJbqIy6c0!)#40v{Udd zWo6Km=K1Ux?~zb>i$?*!C_`-!cDj%(CczM7bT)q|^KbU6lCsP?QoE3{HXW|!7d9N7 zVvx@bR-+46aF^=0e%`9S+dr||R~QN>%40Jpoz0KTJ+-c3QoB2zx%H=~d~%$e{Ug@l zvjBYQ`mFs9S?#%hrHCZmoI1iajqeho zft+hbO5khgex*sgYcw_b%aNZVvf=@q*prb2UV<^%ES_DheIPEIG^=;6Y-b+t<5N*6 z!@D5)c+jXlNzO_ME&MhnnBn)`8ZM@=u-(3F@@6*BDU%dPm!ghj+QC%qzi zQ6-YAN@kPc6L+j^N32hUT$$xOVAJKD{iErl{gWfb?wH!fY#qJnm^DvevHHA;Wq0Ri zT`64iinby^Bx^{74rE%jy3+MBn3SOy1|M8gsyQ&_IALfAaA=v{;9IMZk30djC7ks| zRtxDeMjN1UZjQ67`0DEFy14{wpKHUlr^~i|#ancXZl0&Chv;eBsTk%D=(J>I`EC$Z zM`@Z;`ARwrN3yI}#dsxrLLh*mqNBNjDP|zu_C}t8(}`zHM?u_Kp>LwUAcYms{PfFt zOoyy(5iEwBkTaSW`H$BY2^)Y;#BDfNIW5^vsRWJbivkr_wpY5J$9g(L8OX-Eu1!SS z4jRF}Oj*#QUgj7l~e6tl4MHm zM=cAN}w@KZ3rSLmdfq5NTsdLUkV0s*2iV@1TXyIify2mMGOy~l1i@}JhxuJO`0(t(PhYY zvAsfeg&_HVt5Dh)Up0KP>);Me`R0NUJcLK}CqBFJK)5Q3t{+k<_n#w?AQUEHc^(*DiQXh8$PARaa ze~QjJDs~hSj*T29anyGwflEJca z8y_LUarS$ajGXJ_ud@65 zh+9&I=T`Nw7}p*xR8O^GwGLDUdQOvOxqla$mkC@w{y@5Z-VJfjd++i}87VVpYs#v< zV`>#c|FXXb^mzQ# zAM=eTABy?v{p#$fOH~HzRQvz=)J-pO)Lx*S1|6#By}t*gnEPLlPj5bC`Cn@|Z@ju2 z+xQyq8B;%j7B9-{;{E|LRPX{gxOZ~RBH#bUoXwl;B#+rTGh1!G`tbWc!V~8HMN5nT zt2bF=1|%gUf%%|NK%ccs(BgjK^!fY-FE^rwz+(4rGxYpV8A-z1o0r0-<}KqrENB<@ zEW3?W2&}Q*H zB{*LyBr2ScJiZvPS(LLPhU@Dd73&Z4k}oDTm4^+LX0C)!igil{c$J78j4#v%7)q>p zi}FcnbAYWS4a~$Z(fgEH0qb`Ws;B)u3ffek`ZrS!XE>5L6go@KcJsIcxjc6Dqg3*5 z_hK5*5k#wpR6N!Xq-xhU*l^*BKYywPRC1R*7F{DV4(7QzKgE?%1m11zk?bFRQwW3k z)N9{I4o3&(Z|18-?q}W3pHC0_>j$O_VhSynVGIj~C!#k)1VzI)S=p~|%bGR~!3NSs*n{DxM7g2>D67Q~l&e)_oP&*i9U|)~IZcY0VYp3md{cQ8&*I9q7RCniRYeYMJ3|xM z)kWI!Fu?a;`;lae`JAg;Mat1Tee0B4wetUr;)Sj|W$fwVny%K0A%568N%@8-dw&jd z@!h#+Qsd@BhTAbieqYGu6<+n=j`w=-4T z&jAeaY%HT?v3*EviY{2_3pn03$Fn@@fq8N2o4UN7f3mvnV?N!g>YlN%dSfxIPWNsn zS(-#$Z1Q%^r*c)u=zRlE7*HSM;??s`j)(pFArD4Bu$?*k7e~+49ev$ zN&5+00;(-VNcSNUb$CVwTcsRwoI`J`K&HK!)@!2=&|*k_UX42mbD@TH=#p9g6G+ZV7KB3U2x>jjYuw zlntl1-)HxtzNlRNn}O5Y!^_u~qrmc@fpn9b*}Q&djcMB}%|@T^UZ>!BKZPtaPV0QV zX%j7-WABYbVRNygKiG50I*ZMZp4r8oj2SZyIs?EmLSis=TIh<5|7hxsX#7>H85GZe zNFAi2ASJGM+pK7>?RIY=`oDYHQa{~sXq4D+%}X)sVnfR}ncjORZHb4f)pQtw_iFj7 zWo!JYvlgbchoS9j{RDmT)M5wGL!$weH)sU}VFYf^N>spkPUuR=B!q_@7mHIy)hEGq z`kvG%m6JxpZIE`X?zT>HUp(fKCOoPN?c>~2uSfG)Y|O!P@uQ>YSE-frN6Ukfj_Mgp zv-X>G5kX=j)t&0Fm~jKMmmno~bUIwx4z5obMXjfPlV;*pLd-#F(M5KOQyqh%X0?Drrblh0^g0N=Z8y0y`bjglhBW#lVj0N!I)t65E8p$5bx6 z3^KGqvc|OAH@abRXrwQ?PRZ8CM&qb!i(u9fbyQW#J430xaN&-2GO!>=ympM6`H&bL;-G3NFRD37o})DL zJ7joJD{*1|zyGtRe|l+a7qRABHQ(#-fJ+L7!|RNqk}Bij>^}Jg)nu$-?waD#zt!h& zse{8Y=z#R;p{RPQx*Z^xB36MW^}zagP4foat?_^?=t6T@+0#p z_~PZ$14W-0Jg^#KNQEq=h0)8=TD(Kdnt{hzEC`)}r6=c&B78)@jM=yrLY-=7tc%g8D@4Rs!^?9oP93>n?^sWNC666uy~(Ca`u=i0HeE7e3Hwg%|~Tsm{hjv@gDtV%8|6&l#O(JsGy8!(I$&iPKC)krs{}m$&G< zJeqIv;ATpOU70fvN(1sS#r96nuM;>4NpDhFfUR||&^Tw`u;-X89!o4IDi(tXZt~!2 zY5Z*5Mdb#lUwiWg4=k`Wi@?A8!c}@$PPHlCJ?4=45Yh!9#Yi6=e8KX1?o9SalWwU^ zgbJZeRCM-N?-za{DGiJD*rSQ9*Kx}T=mMpRFx&TypP+@G%S+bmJ7`)u?L?bSMn@LLI-e@3aOUb0of;!ni_yHWvP z=ALU&q^)Q7KJhMk|LRM3DQ3qY)s9M8zE7!>X#247(AP7K%>8uAU060w(}h>b)#D6v z7BZX*f`0k>fFKlmAoNsu8ATfBj)@kgG!Y7A3w*7v;rFv=OVN7MUBamDL!IK&@pN+t z4Qe2~hTZ&mt0Z_gGw!q|Z*R`aTd~n6?bPmQCVza}szs-Tu6Q${d2D5r-XL!TC&1@d zu&+|iU3a8Uay-<%oxYV;C|9QV&w| znFbXEpS9~+hhoMsq-gbVB&8=o>-TFPhhH7V>E=PKvHsw%4tv6ZhIt@Hy9C~S0J zcdqQfhZi}+pD=dSeQqsu&UABf5eI2vksE2yJ?6PLAK>q!U74co+}u*F-l8S`JaKn& zyO|EmU~RTr!+zmz0l-ZYNm>f@E$SOd`}FEhg4ma=>*+irI!UE}Jh?j7(ZI{b`2(Vw zV{&M?$d8ESakOocBR}DjMu-K5CWhYGkta7^UyEs2XpbuytN_fItFR3D) z1cv|pIui6H`IkEvQr2adaJEMFii8+;iuEsVvgZJDa48srr$+Q+-iKz7E8$tjs27$& ztNW>Ttg3yIvooiy+QX`}8J}kNcT=+Xzh$AM_g%LBeDB~buC)VOi+!(oXtTu7G$Qw} zWcsz+J=c2$CR5{K8sCqsUOZ(Jo!%>OTaCm_K5t{g8fTweEI2ncUS|z38;h|B9%Chh+$YIQV%-8u@7mWT!jaGUEencX%)Vl#LcSum-A&;6Eg(yot zcrE_v-$PwVx7qt+lb##(o9q3>`L@QAzggCU-HRquNWHX3Z_bghC&OFn`!3#X`J^w} z!{+T|WxlEe11rONVrHGqosY~n3J=A2Yd?v+JZHGtH`mPprcgm0I=nvFDh|_qalUuD zSZjXH%VHO|%?HZaZm(zGLSL_i6lCo~ekt_p538E6m0k^u$P`0vqu63X@r1`=xnst^GaC{X&^p1G? z>%(<4L6_N?$srnXy55xtG90cNt=17o6YOa|&4jxAIPG`MIFAu=diC-Bw(yp&`9|qf zt%)d4>Y>dPAZwui86;WDHdk&LSlUk_t!;Q)_kfxB|MLRIsw$^#(vwF&fX44}`NJEq zvlA?LN;8K04RsGQO~lDVGXeN%i~_}a0jFCzXDuWDRw|@_Xf5vk5clINIqT}{WzrT( z7=sXZ0Y;J9p+;!1IKV^A1zYilH5mk2T1%*Cp=*yLJ8h7eq942J&Piu2(W%<6ISu%F zIY(5=#ffT)86Q3#Fv)*@^{-BR|9Sq|d4{X;LA$xMsVQ@bu99(q+{_m(^dbROici$+ zaB*9>H&W;$VN$Bf5S?H5SHVAq{#R|e%QTTZ(MBp-kQBhKYt&x6$3a+!p7*oRp3d_9v^Q!~47>g^l=np0zM~?~a#yzdd3wzV2M6o+mr&e!1xtT&5$E z&sRI#&!1LCe^jE`yE@5*Hj<_yZD2oYfu8RF+$~wjD z`%Rwet3-+!;sN7!3S-Z~nC}7)+aVFny=1lftMO9t{Amm=aD89&I-?rM$Lo2Dla@8O z)(bD}JiW3Yw@w+8Oyv3%IaX;wgvFb_reLteYb=u_Y_yxGJIx)$SpuFCR7#HrlR zCd7IogoqYi5NUV=0<fd--j_$ z<~+%^93kFc2cMTL z$SY`omd5q+Lepp2+d^Ak77&lNf>iyKn%(M2qDBP$d{d}5nXs_m20yC!%H$$iO=I@V_eGBZaU5ST?cUa)@S?860H_xM?oOG6 z?WW^wDH;P||Fd#>4T9{^CMmkrGxgogJO%HQZX{Po3P0zzZ*M|O2^%0$ZGI>b+256r z-VT1MhD{4t#2?oULpaHFd`aP|jaLy38zEgP6F>!ruAjU4(+e4E;mR$-a*FV=)kySx z=h8e)bUx-?h6~ZBWHUtaEb)|h{0pVrBVgD#C$6s2?dVC}`A{q`Z{3@=LNSea`vQbz(E;hRai$NX;*_1tacG9r zu>e-q`WU;G$aYEfXBW?5!kV%vx4ee^A@lBo<(}U*CJts2<*<(cu=^SZ%25OnsE3rz#C7 zX?T%Qk{*lApl3+UK&KN@g&uMPbFyyBEYo@q5RQk1R5MN#w$<4+>V|pC5@$n40&aTw zLf?sTYAS8jq4Qi*@53tyb(NZ^W~y;d(QxWYFxQdq@ze-xAjc%RMl-FzmC4CXsdB{~3i zL6pVs^4yN^L1&by#)j3%olUb$tb{@WvS-&i48y8vw8TEcR0zKeLDbg+IHA+jD(<>R2QTfue*28s!VzVxdXo+De4$I8>l7 zn$!SEThy}|VlcTuP!LV;ruS*L)@IJLXP0mJn>j`JgY1s4$|i7`TlXp3&u=9>aW}O6 zye}snRIDX2QIeW+#ocQ(koo@0q4a^+$JVNFY2+TOO&wl=OOxzNg-n7eKHq#K&KhWC zVq)Uxf)Ui`xvp3=5LwB%ibbs0Ge*O=(%`BYgw-nM&{jqdbp#oVee3RBeAN3v#c~2! zL}DrDwaZ&{J=bkJun}ZD)*k}(o;5q`Is?;^(>NexUktVGecxWG05eds#=i$x0YXE( zSMxfK2=c92geHqmOknPWh67oaiSLB9?*q*XvxVCM`@>F^vgk##t9i!tZ~1dY$yV{K z)QflhlM<>_bfmdq^UEu{A(@vr!&1U8Gq~MWenz~xO!;-!%tMH0g-PorL=TeUcdEp{kYVl#J;~t@h?xTwg~b^E~@f5QR46ThSxJR+W=npCWT(|b90iK z@pA^307g~jQtYB66ZiGx&pb#VJ?ei}5>}clYV82o>+NRPCzpxazro^R6$>OB-WIaZW<>EH2LOC_gl-V7Jak&P zknQ0bPGgTA=&RtruO`l<9vX1jIqr%pW|A4(eN3M)3E9(icN{z4FR#)2fnOd(!D5>^ zd}$sq;;ivf`)O(MBsgi2V`&laR+BhtYf$I==mudtrV43wLAXI&zXP-2!y?lKF6r(6 zAmU|8h9tRx`>)f4yBoEDc1o=Dp>6~C0S~B{J=0a$p?cTDWwjxl(C?NeAFroM>hGtm zThqr)dlhCP|719gQ#7|aCD!Kd$A8L>)&Souou~MmmM60IsEeO~1C@e(=&^LAJ`X8o z!U}6rm>qGwlKsjHr*B(_?>k>;yLBIX-QsA)a(hBB7^tu<+ptI-~|UsvX^BzoW2{gtPaeV zGBdjP4Ee-rYtYmBSR`SNfz1nx8*XzpXD9<`fVZ{g^%(Hsj(L}m&`mdapCh*`u zxfZ`>2k2g`JV}foNoD(+!z*wrv=JI`rI@PY$LLt$%#2xj6XX-C`>*_L@rgrbjhyokANA;V|5Ok?c51VUww?xixmSrsXH66>sT z<{JGXOdixpS7PhT#JbY!Fgb1gAaF6i>VEbI-N&nkp6budU&eg}(iWQ>C8E2N+=sWr zUe&ED9|CnFRB*Kmf90wZvGS_{0F0qv%Kd8<$;K(y1R|psjtJ()`|Qp^WyiYsY26~} zW)o#id(@kmm*cLl=(Dxy8<@j?tn`aOIV7^r!rial5p4;~auc|nCtjed+*YegY?5Tr zf?G<+^~lMKD5A|*sOO7P;86kHd*yRLgN_YLg;c#k5=81mh2;3D^Ks`}AlFiuf(J@C zK|vc{LYfqRKbqcMHqPSWgtTzvYZKjLn(}&Chr0QEIX}qYxY@gqp~IY%pOm0cd!*Rkk$uq?9YWH>keXI9D&iTq_?ESN;eTw%R_s93UuZJB?FaNl3b*4(R zdNIZ*QWYM$I>t=^*aV&nI(ilR574{w|DkWQSA<%9(&{Pyks8Vy;B}|;@5il#r z@?~Af;&06=bNETLZao7eRO{E+Y^~>AJe9}1O~1QROd%2jIJ7LBnxn;&J&}|yOZNjY z?XM{*q&0Nk&B)BWwNjT9{J6Am*aY<#HEG7r5Ph95)jQ%bd%~5EilOz_z4%QOS3)wm zoP{F|XGK2TB3rswa;SFFRvw_|tAaV)>UA0AseiJS>>5@>Y{8QJAzifk=`fB9$1bxw z)_kt8qqP~%1H`X?H3Cd(1^p*&4fpUe~?=_K^F<5Ii_e}L@KMgnz`B&|Zp%*J_A zF~OwRX&)1 z^$-^S^5g9t)0UombxfCvMEHm4$TW>V=Z>~Mcjz*38~_||LjerdKU-PT&#y&y^9nBS zoY+5jM;F4arD(a@r`wS#!Cm>1^mO9fR1|u4>S)bPkP=)K$=xAm^_IdIo}8m?MciL7 z{U-CmQD7EZ#uCSUh9f_iWZH&ArVxx4336uNzkNcV!Hd6N47A8VT#5sM+{&Jl=trQb2i2&%yQ#Z5* zP9RoUgKf4GTH+smqqI;*x!)#q`S(+$3CGo*6fzH)M?{0B%tJ|@o3NDTo z-uLwK4=GIojagrxv)#zsipV$Sx^vud^zQ#gauf|)PHW3UgjzqX#mhcUcGR3M)mM6} zSfQ8&;$@7%?K8}@IWCp;c)Rkay2>~2o{DD5F}Jg9+-Viv8Bi{bJwQ@CDKBNMQ-N#K zRwnREo~*Y-*-JKBr_T3D98 zL+7xWF|%8v8|t-28q1;md=lSW;Ll6T#;oSPNUEMJHE1Ef8qhEHzDG^e*lOc>+btl& zX{~kdN5Q!&Uhdyp)7nD~SX}&`z@rWTtDvCx7arhWHl?CeZDH(nNMRlMlHRB!=sALa zAx=Z$c<1R&7fYuLl%`eeC>h;N9Od%Sd#HXGoZV+9O~XRkpW&Nj*zB%Dl#p#@hNi@& z`r=UoG-v>M=d6wpTn_+)NJvhtN+_o@DevvgR z4Z-*SG`k`z>l=T5eoL0e15$hjnF74sgRAS`J)hzDhS)-B zpt|Du)iJVNVK!v($_cBkwZYo7Lhrb$lk~|Jj~+weno@qIrqmVoNGiUgrcCVq|Ga>P zXK(%HR*(Wb7x^%M&M6hq!pO77XjQapvN>Zm%B0!7myu(^bzUk@OizNGVH67$U{Mad z1h1Qh+vJ*Wfak1W6th2{_Ij^A_QwB|^hE5@Vp??~diFXYnNNTovVm<%uNL(#6D z19ZCq1^D8|@I?rP)mvy)*yhSLurZB%f87`BuB02mg%_wyXYKH*Z#Ga#FtEtk7chV^ zYl)xv&}o|)0o2;y*$w;%cHjHCWtqETC%j!ONJ)WJb+mlpdZ-psvA5Yq=akc{Nx zBnSDqYKxNYJ*yu&et{;+f1DcKO?}UrHbaG`HC*s3jq--Hg_WRLFSF_69e9nDj1o_R zhL0kR-(Pnpo#FrEe%$oZr~98uPjERYDCZ8B{ph%&f;Fs~@dO~TTFW$OjBZQiFmYC* zmHm*S^-czkd{1-Zx1CG2gkkUcr8|fzW5}C4h%9m6cinL^ zA))|DQ2WC6Y7qgBoFfsQ7lcwE)WnPjJ+{sX5hvz$hZa{AQ^?GY?ls)l4r1*|GZcEd zNGj%<8*!9?imVe%PaTZArE>WV6{U$SDUMPb`T;ige#P8wDN-deTQSR7n=Pg3r=tlT z-3T%8fb?-BCSk%?LuS&*z@*lI&xr!4RVr^}a33r935+Z)1!$n$k*~^lVgff;CDnaf zyfWdeIDfKFaXy1MPhE*yy(?il(5Q^@=?{^EDnd>0#d%4l7^zE@b*rbFF!=}Kg1%tz zz9q-`27{0mYnqDw2ZViD1H3_vF$gtpI}bX^c;luW=M%SPKhpl?Gxe0$v6v(&oG+?S zsroCa^iPoo);)NtUNMp^rcxl^wwb#|w(n_J(TL_i&Ss?Gr&+*^Eft=B(c-?5*5zH| z?X}*u)fMySxM1Itu*h}b(qsg&Ei+vwiY115rg*=i#=Fs_%Iyt0{luG5 zMg>dgTgSI=-@1bT)3X1?wW-w@=LO2EQ~+cf$_nxGT&#DH;fa2OSn#e*SkPBuE4Z0b zic?lA9rKl98m*eSY=;y<&yxFXWw=oQK+y9pBhr;T6SLSocpH@{^_ZuQu1JldJVqmW zO40FgW113v0SfM`eVUkaa5_XB@>+pLETT&%a9# z$6X$QNIXp1sIQpquHTW|49l(yuH4Nqf$k#YLGLYlAapW*%`HdA%9lg_izExz+OUL@ zv zFJ9`$KYiMtG2;mP=68SHdU5|=`L6J6CW*xF_!|Kr9+5siaDI(y(YrBO-@>WD%Mfv1 zdmL2hk4=&t@<`b3Nvn>!zk4AoX&(;rbX_C9MbK0KuTcb=@7kP>b=5QfCZqPh)0BZ3 zZO8ZK*(k|Nj+ncdYjPG3kGDTZkA59L{@t5zD?6to-{!PWIKpidWRGHYk8`=NLtQmE zVS=}nni!5+@VPn%tvJj`7Tt*M%mKPL;#1^rrFnK@qtWy5w zZTI121FKJ)H<7-~7l7K{s0BZlx*DekM*iu^OX06w#xl5Q(?U_gea^&fayuPK_z_sV z637@n!Y*LNR@@?&Ugoeut%M8olaYVO0k^8}3deN2(02H&6F?x}BwApDypp3(v5D;y z#C`^UH*NvL;p8qR4v|{F&7w12^OgG=|9+^gW((ZiEG;U@d%m)_Ui~ml{d1DIR@?ZabJLG!PCnYn~qVVwKh%PC*S{d zA}KxQkC^^?a*7aiRDqd8B-7v9+uLj9SAu_J<=$Wm7E~l*IRtV`nEc$W&&cawX^gw) z`y7ho$WPioH}l`ph$zPKUUh2B^P**M#u|ZR>Dd~fUcLDTXP%CMc0*5P@%Iei(tC%0 zJ3}eaWb81~IiWIt;hYg_6Iw8+H&j0z)+zw zyWNHDU>_+z)@Jov>2*3CU95)*E)3E{y^iNsicyPhjQtGhme=&U7ub+F^63 zt1CpOVJ^|3_QIw;?~eu-!O9+V$7n1Wy~wJm>Ix^>ZOkIxw(IS8V`zM}G}A^a*0s6b zL-e%p$$0Bg%k%|0v6UJEOiDhig1bhLRzUp5^*WuKScU!*OT$1rz7}?1b8VI$q~L%1 zevPALXYIs(FD9|$^)WaO*T3MD5 zaneoG(sf-vPQz1}bmCaQ%>a1+vEmk<+m5SWsKW^3Dx1WFw0A`}oaM7;9vTF^)w;6p zz1d_po2CV~_|dE&lOIL2lF@tdY3ROhf%de7DLeiSTm8Hi+WNdD4aD?jYmO;S`%rO@ z{vR2ZwnMgv`KO5CLglSR5lqcTPUpM&(uqyEGEbd+>u&$q;o5BA_G*26RdsF_3x@9} zj}W9)B>q~K?e8!9j$rJlr%B?OqQFp+NDK*MT%alxBrNkM8po5W)qiOrRm$%d@2Y)% zrdErjh{Kc?mgdDXx>`0Gdh}Cu`+jug-^)9C1|?}d?TD4k5<~N-E%`8EF1%h3<%rK? zOkJI&b@tmZSPWu_@IOge^IY>(8vst4!rgVVan7cS3&25o z-ZL&F(|Z|SpagiT3U0#YCJgFS=Lc^z2U-SQ9GmaVnnF@wwqhQC9V9U4Fy()w(3g+* zel{mfzehs(aH{fqp&=o~m+=6*PU`Tdts9af zQj7(a+v@9Zn8Ttbxl33b>lryY+=>i!8Mjm4wQi9>+yt*2cjDmCe!>Q~qzGSz=JpGd zHonS6;**Xl<}%lPXh!`#d>qh7^94%zDF55nzB_g zKhkMSjm{i=08F+?DH-zw5f;J|Ge9IC*;w)JV6ldJkml?_-SndI2{)E!TXvm7j0D$t zebsVeRD1;Ho>a;YI`@ZxKX*zEAX>W7GN0|SUj=g3S;){WdH+7J zjHIJ7!09W1B~3nSsqmgu&Fo%?ju=KXZ2C(RBy>^1kEPuY;@S`AlJYGh{b3I*#%b3Y zLiV&E@<+%=96#iru?_=Ypr1gl-McxJqGNulRo;4&bJXqzmR~tis*J+B-G4KjY@yCYt`CaeezCfV&T^HgL~E ze&w+&Uk}QAVPjVDmtYa&*Dcn!x{}0@JRD;k&hV!M1T3~5Od2;^u{nctxJgi9j;lwg z9`qn-`2%f@RKa{9{&K%*RanZ1mdCo{jiQ^n^aUJbHNc}O01Qx*`?JNyps~vN&?;-Z z*FW=H4HL|#TR37Z2tKkU%r0)Q)2J;vO%QD6J~)0iPDYRCF&FVc;%>tmnuaR%?RZzt z&hCgLf+YbVI~j5%!Ytg^X1gHbR>XGM)0)M$Dc|bBFYxHhI1eRYudGFweK0VB=ec$f zu!JcO*dvxlVtJ&R@CPeS!Jz1k6FFeo8Tu6F{gB5y@jA1q#`lssJ?LyNi})S^*AQ2l z5fC~I>DpEZkQx2neD(Y6joDL)Ea%$J{7Bt67s)fcHjWn}?tSKww;b#rFVOr9WusdX zyNr$-?qREWOfE$uckS4Wv30^AV{>W)4tP(W#4XAmOLMs#aY6a{$BHEwFFWzxx8HXi zTm=5zgqQtx_-&KBU3~Rcg0j}}A^2s!sA0+D%|tnG6}${cs*s~cC0bk!{ANq&B~c}b z$}=fSE0Ad@JbNf@HfS8lnKeb%3B*X*+{!Y64Zd&VZrc}Qu zjsaT;fI5@Bxgd~)s}DG4g(mgywqGIT_-pPMu=X$bc$)I$tQqG$JrRPN2)AKQl>V7U z!1Gz0Zh!aL!S+M;1Gf21%ga+UVJ`uOmni)9uW!TxxnE6xt;FF2k)o+^K3%9EYLV`@ zYzDekjZ9^Xuqefo3f?y{F~O-2_n6xJ{7OX>_%7K4p%UY>TX$_Ms{PniCAKaCF0e>V9Z$@r$sksMX2uf(rxl1^2t|{B0=T_U#+L1m*R21d3R? zyo)sXFM5A$k&F>!=}GakeA`Cm`R-Ydl0$f2hhZGU*t9W~cKYo*w-mOlL7%pUH8eQ2 zAGpvb14D=C-7Vk#Bed$ZJeWbICflwa?AF>j4;Uu~y>LdLo~OeZ(u2=`4A3y}PtfMy z`Nx`BRnA@gD*WbciOxHRBwb6eS1X_QZy9}mh8RcvB91?Z*RSPWELF4tJ=g{5vC3cb ziGJ8WSpe_>nt+S`(XPMGDDm6UM!jq^MfvZ#?z-U_g+;L8AQv2d$zPt}z#bd*!S4OP z%Kf}#QqBUv_vQ5mA*+8QALX0@RHVF3FWtt5EEJ|ruRYJ_+PWVz`vsS}@R8g9ynu+4 ze^WS~yfJd<*H0oTSxx3*wCZz|ArY_)ggy}Wg&rEHba5lX4 zR7p~_`nXn405W#P(R--d61e=DGMYF4eliZTFVV-H@+{&vKqmqei#tXemQMOs zJD>a;7chYj6Fqs02;{N|o=Hr{MOl4#C6x|RM!P}|_Sw=%muhNjYcooIH`U8LAKv$j zy7I2%5bVL!&6X8lPd7Cm%oKMC_{7Yk->XP?=QNxEQtfH6=f9CkgpAg%z%!T@MtyYS z@BX88cB>Mq|D)+F+oEi{HatU0OUF8qXP&G z4N^mQ!+XuWz0WW3gTS@Uwf18_0_HOApLa}F|N6^y5afYcE?j)XqB)Q+2Id0)Pn}E( z?s^@o;Jb#hNt!#e#6ra9F9Qa}LK^jaIv7Ku$x{}6CY2#2cf1rIBY8RsZMlSh-tNg=C&aAv zs$xuq#4(T?{%@3X_BP0TIV3_sqFzAI#JqeVR^7hI+pe+qT1Hv!v-}=*kNLyU)nCX> zNv7vqQ;BWkqnXtSvHqN3Y;v?7VYIgM=AGbzIPft-&MJT98_WhN0X zkVk701|?TW=IndV7HQ2odoJe>w4*Dc6-EIS;UyCOfXyd?EQ#Z{_`kD9b;V=8zjjEy zR&OF!M0l;*3#m!ncL^F6>D#ASGcn-)ZADs`^isEM@}GWisr(z2LX7StoaXjQ88Z6^ zB<|>zidu84&(l}vLZjr=#=i)3tN^;KB=rHOcU1vrOy9hjGdrD6$g;oNz^O&?*7W9bn(6wf%qxzM26@^@$gQ+2 zz}hE=5c|{QC z-IQURRszBK62;CaNc<4kyx_w;qp-KfQEY%=d8|*=9_1Y0-Mr7Rux_3!5Q^pb$W^}6 z_HQ(LiMS5Qh4N?HJeY^TC9o;FBh->!kQGvD}q88wn3$!voh|0n_ z{G>nF#%wW^5f5TV<+B^xmp8=O5=@F#GmpRoP%I#7&*$xz)l)1NM!0^$dFEaHeQaol zN^KGK)Crz5f$Vl& zeVcAs;vnU(b_1SEzJbEI9&4&XSnLq?1WRejxPP^D;9R2 z0JBMR6l(L8)3{nxx_$`!Q~3T%<||tOhOr4BLIPNJy&|O1X3JPM+Al`Bj? zwZ>q+Sl8_ED#9$Fe%6Qc4k0!8g#ANFi*n#O}b4`X|yk@iHXFoO)JO^PL*8?jspo= zU&}zr8gM(Ab7}#eh#W@voA5CTvh2wu-N$zgsdZj#+WdBK{2kB!-~95D%Jo`Lc$Im7 z?=W`QtgN&53t%om%kUqB8em^gSkdZ_LI#=LiE9)24uw|aKAj2A}KKZn%r#=7qY4Z`hMVR zaqBo-i>1NhkWZ$3b8`w?*)wRNWfQIDA$s99N}zkAX!5Ay&GD+2H89FO=qoZHru$k3 zY}*wnHYW84f8K8e!qmzze~*+Dkn7mA98_6~Bzv45uB-?waW}-^nFg&0!Cvc@-C_q+ z+b`pOr2-zGetVM@DSn{!>*42wol(ImFn4?z>a1}RR zB?YT4M5<1BhmJE;eZiExCS)_Z z=+9Kq|N220v%cNsSRa6wc%j?-HwP%ePM)6ww%3GU16nZWOQDKw+;sF@Z+8jVBg{ct z^%g`1gvTVyd-ec|9xMST?vCXKAS0}Wr%+vL2NJ3iblosQNoG_q?$=t2?+3LcCAdSI z$@a#CBazjKnCEiOG~o=pvWk04HKo+0?3G^>C4GX0HCy1OsxN!ZpMnb?6s6kMj?S=r zZ!c=ilbpgHFeSp;yZLZ4RH?{^;pr2By|&%al23--d711Iq2A?^DK=i8LgJ|2q+u{% z|C<&U(Wz9ZSdxUD!fh?!z zZMx<0J^BSvLT0w1k9U1{VUtb!V|IN@{A=BXvBXr_s)O6Qmcfwkkdiq zp$(TMxeGGqR+WvP+P6UuJBGUnB+Ea6FscZJDn}vhF}LK0Sm2Y({TJ(57G5>)+MvNe zp@?d4gGN1(hUP*zy4DaQGV(}hftJE*o5J2hZ<6r3c9t_`Z}Zucy}iqG_ns+D%9X-V z&Wd?o8Iiu-^t4FVzRU~S$7;1;H0Z0bHeQ@YG|`d#o0RFFsyUzcNZH}w2X77Whpv;} ziplsedaYZ-byja_zHN64&ycXln)|7Dnu<7ljS;iLajlUv(ZdKY~J64-*y*j9Z ztv0goPO6dDW6t-%Nf^pas>_va1V|+-y?>Qxeo&*^H7ywXHgtC`Zl#cZR=?AFi*mMA zNb@Wn(2wt2Md=$8B0aVT4k)~?PbL!c%rIxJQdVhX=-DaUx&HGMRQwk-*%3Pg?=3M| z2Dy2w9pP|)29Yl^g|`Nk1BSI3of(xUS0%2t#ik6wAE9U|;x^enD*YXh1gL6ZR}et_wThI+`QQl^ z#U1*sM~r5C`@pRV<8bvKZMJDA9^(`Brt<=0I>o`1jjL2mr58O{jAq^cOuPOhryaJ7 zIq;~7!S<*!YdX+M6(c}GuDSG`DlF^hh}O!)1SP4G=8JAlMz^U{&fs4{uoONWeEsz`!`8bW?ww+!YR~zx`@_n*-Bw1g$y!)i5Jq?*7V9eXn7{35D zxy1FiGzkQhDz?q&S|^s`dmW8%Y&MUKe%{#Gvw1+hVmy_K5;dPpq^uAma*b9OlL~YkI^|!RfnY4beO=MRs~X zLS5&t4Ampn36hVtli0N*%E(?aOhu21?)=Nk_@Io%1s_t>UsS63fK8{-nv*Czh@Pfc ze5hot&hk9FjVw7|c*x`E;LGGiy;@ojG>G|#*&?BuVfyoQpYPz#l5iZUZu>-Nv4^t` z{Q(pNBXFUEhPt^&OUPLdNFb*>OyaO4URu14m$q+R#FDoFY*CS`7qQ@0Ao(r zRg4FeUO`#TMUc&#h@P9CVO7t0avfV>(Le;x@x4EMdwO+g^GoKL67C?ShP3|xOHdUJ z5vYJ*?Irzmo&=I3M`dY^(@9>}bL0CGSMgD;>tbLaKL~rTB(_#Y;a_FuDkXYPZJS853C%+ zin-OAg%Yo$q2?~&rD*~sLvb>izfpuL-+v7ch(ZPcye3MUgD<#YT zadk%csAu_<0AYe&V9iKo`FT}owgC`4fI^<0_sP<%2#Y|t;W&c)s{_O*FztI9!93s> z-bW_~#O7=?oBepycImA7fg{@=Eyyl)29p|ZisnJpcfZBAP!vL}A4>=Eds8RyDKpNE z4i!s?pnwzuzM!}vS|g=AlokS{mx0#0le?5WUT)<{(MUs6oaytv(gZz^J1fnjpY$GTkGp^?fx-b^2V~OK2{Oex$<_=zg_+pg3o)<@Zah4Aaq@v^8<`mG^ z(L%6Ol~)b#-&WdN3C?gOq<5J@Z7wU{{qr@yVyIXTfDPrZ7puf3m$EZCijP1qa&V_AM$ZtHwGR$Q zh`$Q;Ja*OspC=~jzw-wtg*^QChDGr}jWSe#V&tQEK3*TtvO~(gLSEzC z>@W1mK!r^oFM#;!%dIQREMka#t!9ST(BL1-wb3s24%cxpDd^LS-;EkEW%SAl%wB4{ z*PotQ3$)u#m;ZW+b|Y_bD=0GUZ#MMrEx>ILMW&|kcuPJ>EVloTVv+xVLSQu;@fa#! zc;#!YYoWzpQkF40OSJEhaM;7mAJP<xu|CX5XqH|f2E$}M}>%VH}}Yi z!eR($4mGf5-R#q1W&%;DbTa1eo^c&5G|si^cs^rGp^x40at#AOh+!MtcDfoF8Qf>! z$WRgP^piIHkv{ToS;6eThs)D{UYA6%l$DrX@#izY>Af$19a|GRo=AUfMg+31rDZqI zDoE7g*aYK_DwY2V#-bE@B-OUxhZ|+cfCIfeUiGK_dI@~w*H5?+VCa|fka&K^!TPUq zx?=*~oSXLP*N3D#=g}zOsc~;iEP{aaWx~ylq;2XXrjK|M=yb~|6;*pJoM`HCm&FocNyeD^O(woXI=A1Up_X>p#)@i_Bnaa*@49(UZ zj(YEHzfjVWuy42LbW&178h}&ccRP3vZ6ft{fwTH z+ZQNV08ozdq~KdZIc=A=!Ie&=wiXJT*TB|_jHlgF_`Z9xuzaTJXu<#;c`kN9mJdKy zg!tkHFqhG?7vD`1@Fa^sjBuvqi0&bb4+sGv9a+nV%hivOav@3UQWvw`kqvuHQ?tHH zIWA(`N!mk2i@6EUwwF?)!XL*kVlLM^nmc;sH#TRW`9K;+4`}eMFgv-flYLDUI0FfjXMinG)7K_nK;!Pk->7MC(2W1Ts>K>j^5}IF zKj9g6m-MaOa_}d)+!!^)*oi6^{r%7h&w5ig>c$c|gx?wo{LVJ@YzR}F*Xw?T3tykC~Ke+d4H>6P(p+N*w=>n(D4*Xkpx5##- ziFLtaGJ+tKUDOpZ5gd=>&0PgLHpZS=t{p4|-k|8*Enr>(v%Qm&OV77mSC4oh^pZqw zW2ZFw_9FJa-j8zpE?0Do2FwdDw|z!n11xm%)sZ60sp;Tl*RX)O{%ilg5epvkFlZI( z&b`~-pVhlLacm^@9vWvWi*u}q7~#i3{RP(a(*(C>DU`mFj+EsBrr*hm=y^9;*`esfRnY2T`FOYR+^;tec!h04ACRCRz+JF+b)glfIZ5LY*_ zKv(8!d}TGer~%WOwC<0-+Pw`e&`dECm+`IUirw*~K_(nscajVxJUu4Xl~&GjnWZ$w zUOnmJw9UWxyU(1|sQUggIPy6cL==Z`btHj#)7IqX{p}@d3Pty)#WvWN5+P9ZPdpv# z;8$h?AHu$=j$v`y8}|$HtYvFc`%i|K5_-WMi`J&(H{RX@L7~Vb-r)glwXCX}PjnEL z=^iWJb@#vA2}JG18T)AEsQH9x@JXTXWkW1RT&V=e$7iZN(HZ*KdS+($Ie`N{>NZyC z@2ZpShS~Tb@^u;=lmhQ0LZyX6f&`Qo1c>!=S9GlpP1HTbS}RF$%t5-Qhw zM43UQpXsXgcxBj=ssHmf8Ih%b)EMm2Px({bOP{fG9|z+4cO{8W7W+QBZpeLYj`0EI zq>1nKHf{g`M%1PW`6B(9bqs-=zk30Uhd}yq3g8}@y2+w6HgY^w6ODKH%5ze+zw(bF zj)my)V=c<{w|1>|ny>Xaav=_FZGJK4i3EZf+*M4oaC#@{!dbw-9X+2K%~=|F@GRSY zy1R7vcu9QY_S+Ys2R1xIY6cRarRo+BqbDype5iqqc!Qu3h zc}5%jCJTG~n}=ct2x5>xsLaItz^2r!u;_Lu zcptNV)~1H(Dq&->h29nIRdu}UP@*GOKzUy*owiyJa?TOWbfUcJZ^w?__;8TNNQK8{ zn7*C?vP$I!!Op6mQzoaugARyjDg#8Ey{5*?2|Dyy$xOWVrl_3fzaH}OT5ZLx_KH`W zl=Ur(9EqJ&*C9%Od^tE;8U3)(mw?E0v+KHuB|^GDcTkmU=UOOdmbe%oV7V`xuX)BT z`rkuotnIQh>L1_$&GP4_kWPRN^V})j9wyihH(WYg-%TE3A<_Hn3`Du7p1jX$Y@1xc zq|T}SjV40ji&x7FBl~$j^JvzfDi>5xTJV^u7%QmM(f^@Kc=fP*btB!8BjGtDc2p%@ zkhmbLQG;PHa-4^n6nH;;0Dbl=>Un0{{ij1!UP|%ix~2+EU&jJ*D3b6(|Djg#=Q&lQ zepSDw#~>kH*S6p^I)-Ap34H1Hucont*_cM!&J*tyieJT=|MIzl_0{HE6<&exC8oD@ z3nbNCW|R6mw#NrQshue?Y2DKJSF2b30b0RzyI=n-Yqdo6{4hpe1F3g?!37@F=en$H zwnaT2A8aCxeDCi=X`O5-?{6k6c$9s&Yq@fos~z%nfqZI;Pld;*6V3CHr&=RFG@X<) zM<$_H?~TC*LnD>2mEskC^yXL_HLs~@Dp6;Ds{Q?B3gXG^yLK%hw28iiR}0blCc=O$ z?AJEyHhTrqm?|o=Tf>ajYdNsTecDhz15qz?~dhI zK8kUcs_0xAKt(S?mVPA0s!gN5>Q{nmnhJE}1+7^och)LEczwV(08wkov+gMgh_+ZT zFCLZa_oxuF^8-dHlSuEY|Lp?Q`xDomKeAPzfBUY^*Yh0;Q)UuEf4C%n?jmPPxOFFt zt}cFM9;diIqg3>1EQ5Gaz%lF8$2kv2u}TV@HzUV3?9esm8SuI-W#Qielth^n$w5KN7J$AxBNz+mU^EssjIL6 zEUZ-0mYUxg2Y#gq^`AlAe{U#3g~ml==#*;0X?Ue?E{0*xNyMaUsA3uTm=yy{z~D!4 z7FX%CVs7&>U`{>!WyTmHBi@;JC)wspX`;g{xZ%4@?@Qi5Fd1+qLSvsA=%E+{S)4-C^<5wG9NREEURCBYi*Z*Qy3+)6sJJLN10Flcz(pMdU` ziZe-Qp=m_xVDssfE2qHGjw^l&do)(*!tq&<`a{*jeXsQf@l+&;QI6^Adu|xU?_W|A z#Bx}agIOYnKY=x&?A{oUqhF^u`|&c+w2>^3{oXoqV?t$~F_mGjW(QtjIeDh2!iRMi zIqmI#DM0YxYR8Ha`ruaj093N`O)wFsv7b^^^y<*R?=eOn=SdxVCiRWKF1G1$UD0L+ z{C^D~WK=2=jxRt__)DO_8f3XFVS_vp>S&m%A?e?Iyq|sPb#!7Lf4afmcG2&PgIo38 z2hcX-9uS$iKdGjA8p)p!$~XENv(-yZ5*6l9ja2nPKx+QZVd(5yl|;3BAg9+>Ri1z6 zE`;l4p61$H17G;`^)ECe{!ou~J|Ii7eIl#uM*-X*Oa-A2C+<^?LSz$DyeyV`OLcgrRiSN8Z9H zMZkDu6jQfoypLDQGVoQGT*5y$x)j`v@Kb`{%8Z3ldoxKD^kVhEYsx1E_ucz2+<6cN zB3%=lB_$=n4r2BIT5M_^)#}W+inBay;^WYyJ6e2YCiF-ql6;#O@{B6h4RL z+GWDc0klk z_U!~iw<~0-s_!x+$!H)e3B)7Q#uv0nSgQE8FqrjYw*98?sKRIH7uAT<}Oyv{PE%T+-{MB~gBc z?X#C?G_XHZpA+9)($oLOutU={G-bncif02W@3X?F*vXzAjd~(pdMvt1&$|tCgHz{$ zN(teIc~^J-8=YKdbUewD7H3Q(n9)B`y2`-J@$95Zh&Va=DqHcx-ZQpmx`@(SMeWu+~LnxS2Zp_SUJ!$gzxP21AJ zGLt|+ZGX?&kPg!)N zF~}cl$e$_r$gE?&jqh>ViCZ4(ivnC)wZZ3Ny!5_a-#A{~?40dsPrebM&pjGSCp8(@ zer?!v4tTlaNIgLVK(H}v2k~jA>{F~l3u2nEoj!|q_0lPA)V<}ChcdHNIe91en_siV z?iQYIBuX)Zb5}Ks2TT8=riiA3czU+RmUJ@Jp`J}+$N?HHkOP4`%Lw@#Sf#Hn&_XZO zS1-bEFs~G;FJhx7`v2a&%5dKpSoL2xSw|?vC#`+Y3~IgC%JbOSTZ43kDqZSBeEuO4|l7sX_7@-RP}3=eD`hy9PM_1 z&NmHQo|7(y$Wcl$Zf73jEnwJ+WVQ_v|M#5|)q@1+om5Eh%@gV(`e}iTZAf!CCL=fFoV1t*JrQZj-F zhNttSGoojx&*7y^>?;?PDd~BFTTuBe#*`Ck~5@K3gwJYgqEDlIDlmf zOf$YXH1>XzfB(pkU7wr?BA$YS!&d;dEDs@z9MTFz>;RLt{Q5RMk*Jr3UE6jj_$RnW z=lhvuS(y*`HzB%1IbY-Up-7s2{d}p{-MNef95N0QK|Qgz0JCj|F|20R5}XZ65}aL) zpZV^)Mmp4;9_W195qOhcFk3L<7;yK)CQ9yePo#6pRk0+?%7o=-8KfIrW-aS_-gV`w z&z90css>V#`UW^eG3^yz|D0%PlwBqXS};ebyo1DD)xUl;aoB3ZQSx9Q_EP=md*m~2 zpZ~_{MG0?{n6I0z*}0%REsnZ_{hQG7-ph8qyF8EWrHa=0Yl@T!!{d_6#@9K@hp)Lt zWw|m)2WyDmcCK5dwyNT)pYat+r6)^#y<^VV&q#x>=%XH>!u_No;?k6VT$%AXLK2_*yqWHiXcm# zqCft?&>cu-x-A+MHJh{H7|03T>%2P;kJW|et#rrYz#$hitmcYsiu#XX2@?SuBd)W# z-9LkI`lj@PQZZ!D)K@@n3Iajglmvcnz;f-x4eBh^ZW2hF?wWo;jefO%wM0;AJyBo5Bu9Fr~2{HS76}yqE>(XVt8asHn3Xe@@%B1wo_oG{p(zpNevMVxmNg6;wQq77m z$medCGJ@nCP;2b?7M6o?@VkE}q z@FJc`B!q!fNR*cFS^-IHLi@7ZqFr3fR!|{_YyHpbn9~N$4V}6QL-nGoAm4h^B50+UzA<|C8j*A zS3pZ=$f04D^SSr0qV2CM{e3S??v+6m(rMnqzD$#!LT5cf;dg{VzfjVffQDlq&wJl_ zpSeqyN1(Xl&nF>m^$~aNM^Y=3S=xS)+zYVF6h!4g-v}J$!DNpdf<@ZAT#kQB? zXFr=fNxx+)n_|f->kj*_O~%$0bbbn?n#yPbnRxr#*sb{{SFjO4v>V1`*mk33BpuB^ z;`%2T$&>B$F%I`_hW#5sdJFDdcVmfdC_d{KYdMRf{l-anwJhCTxcOIwV~rxb-uT-n zyr59}WRAX~&8;@o=?$}q;b>@WgfpLYvg*9hS_a$AzUO$X1U4aCHp`Q?>hGEaS|(L( z)h|w@%M*)8A*L_fuBH)P`adRHT;BD&`U(77VIPJYd>)D z$DL7)I*4xO{f3-;pAkS4EDEK0_)u;EBnT*5o~=1$0=delmi2FMEq~a5MD6?z{=S&U zf*Qtp|A|RV>(BK1=)AytA0rlw@s$%>TEl<3g2!~8)mbrr9?vz0&iktge+PHHLf>z| zi+(1wNPFAzU19xBvSe4-Z_0B~Wt>HclF`Ge@Vy}RMmJ212NF<>VrQp~+$}^bwwf0G ze?R#}KVsNSVW!B}KWJGp4;(DH2o=5cHcf2mgtG8?v_T)0v+vDX2+S_Vvo$s%sXO^e zSwqCJ-)V_X!{OC$>*w&(fz#z^=MC)}Ze{JO12Kv`Pn3~-q~}?reRy2iToK?phuga!#=5$ZI40bFP z$ToPAsiM9H!s)z3)=Y5ly6asP(WkSJP+VwjVS7-@=&Pd&5J8VdM6vC6_ZD-`Ojpnkzv)<@@xC)0~ z_E)Q~{jmv;F^8py4r9Ll>V)p?xA|;2g!WdFO+Bhd&Ol01>TlY z^!|C-f7+f3j?55R(K(<#1!6Tsg2duf(a^MIk+>7qo7i| zMrjJPw2_c#%*Pn;B<%``?GAs0LGN6OXXf58ry1esCJHgo{$$|TEHmnv5U`mkCE!t%xd3qyQ;Dp6vn{4|EQ>~>McHiCgTmf#NHU- z$I>x;xiJK4^&(}s>;5AjVYv%^{M1FzN5_F#1yt&Ny34jUOI*!k^Fs$FnaVj95XOGA;e_8tPiZ7sGPujy>E1mtNhKe#V0;gNwP z+j|BNQR7Wgpkq`FZaI6@`W4~(z?-du;M3hoxzNzWuUSGTd)Gb}?}VTeV*B2Vgp#NH z%|!MKFZMTkZ#tGR(Iyp%B@PO7uk13tHfDP*Q`&`a07)`K0l&I{w< zTOAo;j$CCHftxqO*n{d9rzdG|u|2Rp-8D;9rcAe10_JDbNbO}&eo=Qr@7tH;>%f*B zM}5DeW-;M3(gYl9f;Y`VHx?!7Vv7h1_SXi~Aryum5<;ZB(|>3O1Jy!)&yt1SMjq2m zZLE+{4L$~S)%W%lN+gh_f0a-34;^n4-jgr=Xf~1R(sa32nEP*iG<-TtbuVt5mOjw? zP-x}G$z2X`EJ-TXSflCo`(1I$+h|-oUEk`6egs4R*=U@A3Fox#i~Dd>Wu673tZRR= z5-AO97-t$LHeO*sQH|vv83{)~kp)7ipufl!)|*sE_i9!5_;V9)XCF|^JU;Kxr${$V zmUqgB+Mcl>jYL|smi-h#5MQopV;P!^Btws61heC5iKa8)y(8}qIJdl&we}tN_vDFT z1+$rHEEyu=94iV|ErBm$6>At|vg4V1aIK=YDW~M~S zJv3Jan+R9h^>;eF-$YMKYdJzxPhr0;C6q83=#Dm|9;T7-?Hgearms|4s0rJUOdGT>=4pYbMX zrASqaZpCvj)v(FO%uGBsjEvq8Cs?&WOKlG}trrC9TgR!*IOOXi zoWin|)4`g$8FC}xfvV4^CJkz27WlmPHU9oHZ2T5Ts|$)x_iRKi`~j>HM9XLkA}eF2 z_^J{4gxdtXzZCvX;!^#is!OlcDgL{<41by7gS7r&Hs%3k$aVqqf(5c&R?N?ODzc54 zwX?AAKr$dv2%rC7aH=2jH3?}#va^t}$=Q)~BP+9f-wLA60^c&rRqusyal|KSLf$5m z0;b&%sN5;Q+sh|!zMH~^6Ed0yoO+(5s-SJ5&Opu+be{6Y*iQ0on`z?MK+R2{NO%ds znr7gfeFLX^AP{|gv73V)v6p149eO(A`YPGpj=`wV_vq{uTJPhQGBo!M1i!>#=fk6H ztL-+mB`1Q;EWyXD&0!4AUZ6L!^$yo5VW1be9<%Fh8cGg9>o@yeIf{uA`JJTQCw0^B zIsKXyyiJKUy3$E=J$70J;G*YRUV>T$CeT9R3qo0*cLN8<^I^u}-}x<*D4D%?3xS?z z@AG*j6k$&Rw0Cet1lz=H-|?gKble_8cG)vp2Q>{qu|Ea7(L9>n2|DE$OBY(O2hsB# z;p|%M=FUd-uA9f7AhQlzo6N{ygXNj#YT$eRX6c zG6MOik||sxwDvP{{I!MIRE*&Ib}&s?M08rXO&s$N#Zrzzpd4;e?rh`r&?kE1wkW*b zLRNK>e`zxeU^HedL2VmF4>4x)e9mx=gUBUJ<{HLuDO~OplIR2vfat* z=#5%0j2p8K{Z`y*u5p!KM(k+}gXTtA;KN>bUj?npogB)%eVw3a@hg zd)^rOE70iD!pZPJeLbRG%hl7Usf1aCk|jt-9kO57oMq-7qz8X7{?*xOND0kr6{sLS z*u*hRzPPpRf5{R*lu!yTopYGxX{9arheV|Jlc)3fgTB4%_iGf|&`I+E!*|M?`J%u_ z({*d-b19A(qAfD3(|2AzbCxVqe24;GkaBp}T6u9~>$etZAPv zrqdGWzjDo=D01sL51V|I&iu}-qP_HzosWK6S;c&Y*B&E8aB3BGGov3v z?V!oJoopCEkExub3?Uk|{e*X2Z%anT?r?`}zgv z;;j~s1GN2&MYEQEP&yL#El|EhhcmCSx&BsBT_RQ?@M;EBU*2|(V0r<5HA4~&6pb}~ zw5MLvoi;QgBsvUUZ{q$w#c;AwODo3A=#<@d!MXI98TQd?@12X$>bMU4g|zj}UOgV` zBc+59`KdQoQIYjPY6Fsrf)9P>>Y9b7b(gIG`9RFmPzFe6rLD$v2HIO>99tTtw0_o2 zI7lJqBz;L(;?gF6<772IN<8=GsXzw+;s%%)a=PsyUP4bTvf6lz0TtD8w4}|*EuECf zU1%A#)p0H5%XcN4rMB2-Qcfl$X@LNW`E?A1V&(~L-0^Xx>n<2a=#<_8XToN{t}9M; zv44A!V6W+cC~?KCY}Wti#~NbS!^hp##q7*{e_Lz4-a||eR%15xLP|c7X2B>qC2*R} zfs$+^FJBu@3rQI~RlV603ik1lkeUeTEXY4ayoS$Y&y0*juN~+x?ONJ7nDMj-fH*8> zx8IZko$N6_g2K7cLmR$;{+rTzx*EuR%0kLidxc%aWtmkMu2{|{^@&V~j_hon%{v=J zl`y~7c{dp_Da|Yk8l-PIAOV4cEs)a7B$pccEjqR;dRpO0Y)TiILQsnug1_6p1PBo(G)Q`Tax&I0vPIYDh^F{$=b)*62iuzTP29i9TY5-Tj#<2?|b2Z>+0 zc6Zg7;>+HlnUrHTPIOEZ6^P@nNhBhixci)!{4c(BW9+ZQ<;uy?tU2t{A;$68Q~WOA z^EPr;Y95OgDUFA|OWYn>S(s+?J}2B=tr3pE6K_hwgwKzXsB;HEm;7qA&LnQ8mDZb+ zgr1vXdO-lh)~RyVceJkS7UWsrwxK#}Ayxeb(X1)B-b8Yxhxgt@53ak7Y{Jg^ zV3z!eUc5trh{1S1Woxd^++fe!9Z;G?2g2wje8_xZA0bU$xH9m*xK}LCqnFS8)pT#w$_Sn18^A`bnAtH(%`EU!YG}H=xh_{*qdHu2T6GSy)XxdBSM@ zyZC&4^uM}Ebq*Og)}i@5aFWqtrhtaj+L7iky>$9$`+_x<1Bgg0>6(?}lY9ok3?&0m zJD#zUH0A82fr%WQb%R)t*Guj{Cbr2<*O~Y1KpnS z;?ZyCz#0CdAH5xuLgROzP~8enP^bjC*o*P}<1Cqaz5U=OMtMw!jsT=S`X$14!17~- zTIF`o|8@bHo@{+r(j`|@DYi#0P=)fr)LAXsPW38>N%D^N4W%ji|Hvum&(9LfX1kf*5TQz3!nbd zD~78B{n{nyboS1$<04LtYH$ny)~zPuO08LA z#*w&RKk&QL!bV}m0c|D8QR8RFZ^xWOe@$I2-345|`8p~(N1jk^P2^avn7Ds=wuXaxGkIm0Z6{rrsztp_JfHo ziZ{L7LMgF^Yf1(swT+*=cBe1MZv>&C6Y9msB$MnnyRO>7sw9wS_xaZc2p%=zl`pGr zU8EoEgUQqlc01{Pwtk_V`&Tv={?WM09%xhyM3h?I*J`WbjMr8*1|+-;@&8+BTS(us zQj|WV)V{+}$7K5LLK!k-y_Wo)AFa_KH{lv&q<`!62Bh(*)139Sd`at$)BD4u{8+AY zjoWwO+7-By7Q)0>>>V~%mUijORjsl8QFQYIB6bWv#oKY z;s+22N1fSOeSOF>m2c_;cGWrtez^x)>nb>WGs;f_TGO=H`QZif{#f@*CRcfqs~eXT z->EAHUPO$7Uof&9%>ozjya(mTFjaUuYs({>$gCe_BxgwOpaFD9)JGgV)HnjN0!pyA&)zGSheQ>JQKg zCA*i&B>LQ>!qJdg=y|Jvc$Z2#DUYggLgTS430=-6uvGpw5e~&XqYobi`U|Mk{+t=+ zTmG!`I1t|Iy!(t%=7~ud^z1@jOi>JO*R>JE++zq{X6D1Mx1Z7R1g!yCeeKZ1@u06B zirRMfo)V`mPRFrdRA?Bn-FW>>fYb%~gMO^|t*J;R%w#$*;26PJ<#N0aM;j^JeuN$*Vaa8ZW&tdWJoZF?wou+566AZ?Q`)PI#D+r zmZ*Hde!ur*P40Na2&`XGAL4w>)v?_NNmg$G`7B-cQU7{8M_Za7Fg3aRP@vGcR7JkL z5&=AL-ovWmMG@m&Af_n+DW@#Cj#;2uhD)+w#!3@MtN)}Ma~Z(bL}f6&dZ(seeXgbr z=A9e;J#!l)p#b{s@vkZcsj0-@)HtxTiOS4d+E25@h-`*c$#&%CI$C_kt4!g$FVY!` z60p|HMu~nW4}LR=N^(d-bQX+_tsuBSyd=x$S3;qi5;P%>Gs4tY=LWsg3Znnjg5fauamAwV5@wUSQ2#1zq3?fsMaKa;MK#3CGi@2X(;|cH%8-MqG|?&FWf2faJD+|CV|)K7 zM{DsDzLTNrCXY(mE-) z{q@Newco&Rki$r}cpuV`v~ntsV_h#uThl((ajlcn)(`mCcnj$HuRoib5;D6azW_2T zx%)A5cGOQf>gJ|7*fTaQi|MW@-OG-wRKy0TPW^|=*1A}sBiIlQRz;=Z+I1f#BT3(_ z$GRLj3xYTQ$JJZ7MfJW>zcbX(C`d^R-O?Qc2oj>SqBH{1&CoFj(jC$zgEUBo44`x) z-5|}--S7_I_xhc4o%1iup1q%SuV>w#mGBA{EN}OnI!~~9>9OEOF=k>4Ta%uy|15>k zd_$pasla`%T1l=p_XV72Dksa0I>mUv?-3f^n?wGbW8EbC;gcw$-UT{b(Q={oo0Uo( zRO6;=(#YUBj@2i;lY6?`?Xy#WN`-@jAf#XvuFL0K!P0KJNkz~0AyeXu_AaXFkiee5 z84&k6jtMQS@4wsf>;G=c{X)q89XW-m*C&5pJW-}ls4@_lk#N&HKeHxDrF@*9)fpR} zakM;m-v&J1v8bPYw0zNg9NQ^MZiy2g)I zx^bl2LR{Mwv>a6YGLd)xG`@9Pd`!>OdS`1`NTr-=+a>cP2|SmCQWuw_nu-RnFkS2? z>V{3nyg^*ZK>8R)zx$~Dpsz-XF}#Q3!EsL8oEa9VX#^Ue zJoC=XfFm(dB{a}8;M3X3)+0;P(J?i$T3X&Mj2~nEsJ4hMcd{EER(zn)xt48;(%wMP zeE^#sFl43t>BLoJ$=G|uhZ>YMyDl!dspJb+4er|TWRP+;XhZne(fh=Eu1552I;!6G zRaT;?k)$zqOtDNh`0dJ(h-Y!SPUJw*a#UkcqZ^6}z7i#u;){(s>qcUhM1}5A-CoK1 zTXE8o?_ajY{n7TH*Kv897E%n)33oSm1Tq-^_ttyONsM#x7NE`pvBgZ!j^jlp%2Ykk zf_dLp`X_%@n7vWhr!O>-*reH*WJKymVqfc>ur;^`Ug8P;>2JrgdvhiTQ}CLZ=a>L{ zKZyrfO92#VN$CRh+CnT+fVz9lRts#%y1ty{;5E+MZ0!ktBLs4E6q8BIGqC0_*j756j`X!WA|gLDrrsy& zWqeXId-xNw-?h`vvg>C?bsQi}7wykc+=+4?IkJ1iP#l!i zNuzn<^moQd7|{Y6#H{i3=XSHa6|*_t-|I+>e^MRf(-t8c23zHA4lB!?By&{Sw)u6>PZd!=Y>YR_o-SVGh^@{=>L(0cxOpU$b?%ZPUmD$Aq+@>>Bb4U;m zGS4>JAub(tu~9!wO;PEC;^`k7AzK)kw`TUiz5f!9o7lM3uRVnS;fWw> zffJYr5D>1n)hPt;4e(TCiC^!Z|4sTLpacuR4P71D)tZB9E@Jug9hxxD_yD=EW0VRm zDKy(Wz8v7i zAYpi;AvZ|L<O4y}y9W9+NN5glu?XgvSJ0`mrLgnzj*;$JU&G@m~-m%{iwW2F% zpbcY^%>vnBE7K_tj{oyUKGtWkVxRRUkHN_eamy(S*B>h;9j#+KaHb~}tQxLY8-?76 zIf*5#`K(o^9Jn66^2a)jV?G<1vHoE3cN1uYUzM}e5}l^Phkq_QWG3?vDqRgW*tZcc z2${U}7z%&tmAu|w%b5-h+DalX*x|Tb7B}fQl6|ehvB`+))-4Z9EA!4T*9ktw?V*HT zB^^zWsBuB>+<0e6Shk6X%(P~zk{^rN16`v~U7ETw&(E@);oTy4e%AW;nF0~nd>lI4 zJprUsBcqa?7UCN*Y57YD|M3RQW|x*SkG)U_;g2+(Pu1<&H)BUmqe-nCODlRPa3!iP ztCG_yNyHn^wwq8TSj$H^u_n2gXMoJyDM*p*eMHUb47q`atk?_pv+2stX&tW^76 zKHXg)OIP2o{U)0kGm5i#rJ10}v7;NK>>AcOfIwu^pIvvuORtY-b_`wUys?_o^Jh5N zy;DDIo`%o4CNURTl`u(;YP`-bWL2hu=kBVpl}BOr{$kyj#(FUhQfGvpdmT>d^|>Dc zo_4hhe`4iar-~Ty&}R&^v;$ZG06+Dz(ou^hFXC@)Dtg^<^VSvvIc8S1L%(3 zbkZj^G2|Sq@*dDbC4I5si^r@(Bm()m_uqxY^uagpiihI;+AEe z=xPIK3_-0!H7wsqV=sqKouTOe*e9yEJ<7#n!H+#7{9q=({4tRSifp%6M=#|kkbbJD zs-+d=$^*wli&Ir@HFqooXm>gHnVa(N{l_=8u^XKO0eGfdVgJ=+f**Hw5k&T*--SB} zC%Vik&3I}xr)bSR9(U_A?p7lL=K|Kf&N(8!N2+pi=`_=bpbF=I=58Hzoy28dDR~#o z-y$QNd;{tN0BpjA?JYj~K07z$!6N1@9uR@kSmW{C6WYGmV77|4>8dk@Ms}Qv8$g=v zKA?vHi&~h)&;p;WJ(A+!Hr}bY#0WNo^@G4gKL7jh{CJYqB1NJT0)T~@meexKXqn>T zC*KEfrVB!!jxfLVJqudF`!a=9b@j0F&56%(B$lo>w+c!lY(Y#{EPP>%+I9&(GW6+P zH(?YebSWc{lwmo&$^xQey{Ebz;eTL)Y{<}#wx|^xqNz1?Du5{ZY&e(mZA4!6cRMKi z3JsSYCTn$LyAavLuhAQLx11)bxiu+}xwP}vP>XQ}8N zTJPSq0{^@MF4j(ed1odz1>=8Ph!6Wsa(|g_AML^cScjm@us22(Z9{*J#Of$w1-L=n z1}NRLo8kRGFQBMt_SOE_t0X3(M!%ETRafWe3hs+%GK-CxRDOFBOA%mgO=XvTvGV$_ zB0rd%d_x6*s6gD>OhKORdp+sG(}&wrrkStQWWeGS?AxuZ)*;n)c%behX8X`DkRNsxBp8{9MC4NHvUw|}RV@lsi znRno`c68#ulmxHkX{}ZbnRm1?^-p^h?ULYV|6D4K?)_OKx>jSFDg1wZvixV4l0qH$ zh-a+NeV*-S_Xb9T?z}wIRWN9qcSix|L7|HP4Yroj_r3Y+zg+A{L(>F3oq>2acis20 z?>>`^XCJ+=#340&d&&A9CC zM%ipVul}2MbsvJM6pv&BO&0Pgg9o#J5ejQbX6UIzSAglpaO#azQ8gZxKnny%7>ALa zz7mCEc6qfIJ0d=04Sj2Tqom@#QFJXQ?wK;;91pORWM(Y?hqnBv(jKyOH~@- z@tSo4t?BZ>+TNPOcuF70YlC(=?YrQ92Q@FBEJw7IcBas*9* zbZtsRA)aj~G=-)UcLWro20U^>YtsuvGnSB>vFvmJR*LSE^3kQg)Q$3ov3cbJFNh$y zAvr%8pH6wF8`)kbI=(K{VNSh147|wGvza;`EGs?uo#B)#_@{%HZ1fAcMGYrz9T^G~ z3M`IQ>jk{~6(cH0J<;6*$Si(+Rl&1n!ecrqWr2fBDs%SlV6*7v^+xt(U@E{y=J(sA z;y5qNJh0StL)$4Zrkzltd3xCRbuW{^cx~f$yU#$s&w90r6LI8h*dVDg5UY5g0g0Rc zKwUKt!2#VSEcubIRJWMuxDHBm__p228O~? z*atebEE40vx1^$lZ7F_{tM^W79zapEjvkN8xRAhHafkJdbuH061samj{t!OX?q@yt+{%P{W+1O%ODtGCeQ28I*pN&^Kdr+HT|b z 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≫XYf;RK+ZqXSn%g!E}QsdJe%;jc&Z>+?G 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

Eq}!4k@kao;eVZ~ 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 J;-5%S{|BGievSYD literal 0 HcmV?d00001 diff --git a/web/public/favicon.svg b/web/public/favicon.svg new file mode 100644 index 0000000..777c238 --- /dev/null +++ b/web/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/web/public/theme-init.js b/web/public/theme-init.js new file mode 100644 index 0000000..9c834c0 --- /dev/null +++ b/web/public/theme-init.js @@ -0,0 +1,9 @@ +try { + var theme = localStorage.getItem("theme"); + var dark = theme === "dark"; + var root = document.documentElement; + root.classList.toggle("dark", dark); + // Pre-paint background to avoid a flash before the CSS bundle loads. + // Applied via CSSOM so it is not blocked by the strict style-src CSP. + root.style.background = dark ? "#101014" : "#f9fafb"; +} catch (e) {} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..be9ba68 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,131 @@ +import { useEffect, useState, type ReactElement } from "react"; +import { Navigate, Route, Routes, useLocation } from "react-router-dom"; +import { AuthProvider, useAuth } from "./store/auth"; +import { LanguageProvider, useI18n } from "./lib/i18n"; +import { AuthenticatedShell } from "./components/shell/AuthenticatedShell"; +import { UnauthenticatedShell } from "./components/shell/UnauthenticatedShell"; +import { Disclaimer } from "./components/Disclaimer"; +import { MessageHost } from "./components/ui/message"; +import { ConfirmHost } from "./components/ui/MessageBox"; +import { LoadingScreen } from "./components/ui/LoadingScreen"; +import LoginPage from "./pages/LoginPage"; +import DashboardPage from "./pages/DashboardPage"; +import DevicesPage from "./pages/DevicesPage"; +import ProxyPage from "./pages/ProxyPage"; +import SmsPage from "./pages/SmsPage"; +import LogsPage from "./pages/LogsPage"; +import SettingsPage from "./pages/SettingsPage"; + +const THEME_KEY = "theme"; +const DISCLAIMER_KEY = "vocat_disclaimer_agreed_at"; +const SEVEN_DAYS = 7 * 24 * 60 * 60 * 1000; + +function useTheme() { + const [isDark, setIsDark] = useState(() => { + try { + return localStorage.getItem(THEME_KEY) === "dark"; + } catch { + return false; + } + }); + useEffect(() => { + document.documentElement.classList.toggle("dark", isDark); + try { + localStorage.setItem(THEME_KEY, isDark ? "dark" : "light"); + } catch { + /* ignore */ + } + }, [isDark]); + return { isDark, toggle: () => setIsDark((value) => !value) }; +} + +function RequireAuth({ children }: { children: ReactElement }) { + const { t } = useI18n(); + const { ready, isAuthenticated } = useAuth(); + const location = useLocation(); + if (!ready) return ; + if (!isAuthenticated) { + return ; + } + return children; +} + +function LoginLayout({ isDark, onToggleTheme }: { isDark: boolean; onToggleTheme: () => void }) { + const { ready, isAuthenticated } = useAuth(); + if (ready && isAuthenticated) return ; + return ; +} + +function AppRoot() { + const { isDark, toggle } = useTheme(); + const { isAuthenticated } = useAuth(); + const [showDisclaimer, setShowDisclaimer] = useState(false); + const [firstTime, setFirstTime] = useState(true); + + useEffect(() => { + if (!isAuthenticated) { + setShowDisclaimer(false); + return; + } + let ts: number | null = null; + try { + const raw = localStorage.getItem(DISCLAIMER_KEY); + ts = raw === null ? null : Number(raw); + } catch { + ts = null; + } + const expired = ts === null || Number.isNaN(ts) || Date.now() - ts >= SEVEN_DAYS; + if (expired) { + setFirstTime(ts === null || Number.isNaN(ts)); + setShowDisclaimer(true); + } + }, [isAuthenticated]); + + function agree() { + try { + localStorage.setItem(DISCLAIMER_KEY, String(Date.now())); + } catch { + /* ignore */ + } + setShowDisclaimer(false); + } + + return ( +

+ ); +} + +export default function App() { + return ( + + + + + + + + ); +} diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..870b50a --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,175 @@ +import type { + ApiErrorBody, + LoggingSettings, + LoginResponse, + SecuritySettings, + Session, +} from "./types"; +import { tl } from "./lib/i18n"; + +const CSRF_KEY = "vocat.csrf"; + +function isMutation(method: string) { + return !["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase()); +} + +function camelizeKey(key: string) { + return key.replace(/_([a-z0-9])/g, (_, char: string) => char.toUpperCase()); +} + +function snakeizeKey(key: string) { + return key + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .replace(/-/g, "_") + .toLowerCase(); +} + +export function camelize(value: unknown): T { + if (Array.isArray(value)) return value.map((item) => camelize(item)) as T; + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + camelizeKey(key), + camelize(item), + ]), + ) as T; + } + return value as T; +} + +function snakeize(value: unknown): unknown { + if (Array.isArray(value)) return value.map((item) => snakeize(item)); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => [ + snakeizeKey(key), + snakeize(item), + ]), + ); + } + return value; +} + +export class ApiError extends Error { + status: number; + code: string; + requestId: string; + detail: ApiErrorBody; + + constructor(status: number, detail: ApiErrorBody) { + super(detail.message || detail.error || `${tl("请求失败")}(HTTP ${status})`); + this.name = "ApiError"; + this.status = status; + this.code = detail.code || ""; + this.requestId = detail.requestId || ""; + this.detail = detail; + } +} + +export interface RequestOptions extends Omit { + body?: unknown; + raw?: boolean; +} + +export async function api(path: string, options: RequestOptions = {}): Promise { + const method = (options.method || "GET").toUpperCase(); + const headers = new Headers(options.headers); + headers.set("Accept", options.raw ? "*/*" : "application/json"); + if (options.body !== undefined) headers.set("Content-Type", "application/json"); + if (isMutation(method)) { + const csrf = sessionStorage.getItem(CSRF_KEY); + if (csrf) headers.set("X-CSRF-Token", csrf); + } + + const response = await fetch(path.startsWith("/api") ? path : `/api${path}`, { + ...options, + method, + headers, + credentials: "include", + body: options.body === undefined ? undefined : JSON.stringify(snakeize(options.body)), + }); + + if (options.raw) return response as T; + const contentType = response.headers.get("content-type") || ""; + const payload = contentType.includes("application/json") + ? await response.json() + : { message: await response.text() }; + const normalized = camelize>(payload); + if (!response.ok) { + if (response.status === 401) window.dispatchEvent(new Event("vocat:unauthorized")); + const nested = normalized.error; + const detail = nested && typeof nested === "object" + ? { + ...(nested as ApiErrorBody), + requestId: (normalized.requestId as string | undefined) || (nested as ApiErrorBody).requestId, + } + : normalized as ApiErrorBody; + throw new ApiError(response.status, detail); + } + return (Object.prototype.hasOwnProperty.call(normalized, "data") ? normalized.data : normalized) as T; +} + +export async function login(username: string, password: string) { + const result = await api("/auth/login", { + method: "POST", + body: { username, password }, + }); + if (result.csrfToken) sessionStorage.setItem(CSRF_KEY, result.csrfToken); + return result; +} + +export async function session() { + const result = await api("/auth/session"); + if (result.csrfToken) sessionStorage.setItem(CSRF_KEY, result.csrfToken); + return { + ...result, + username: result.username || result.user?.username || "", + role: result.role || "Administrator", + }; +} + +export async function logout() { + try { + await api("/auth/logout", { method: "POST" }); + } finally { + sessionStorage.removeItem(CSRF_KEY); + } +} + +export function getSecuritySettings() { + return api("/settings/security"); +} + +export function updateSecuritySettings(settings: { + mode: SecuritySettings["mode"]; + allowedCidrs: string[]; + trustProxyHeaders: boolean; +}) { + return api("/settings/security", { method: "PUT", body: settings }); +} + +export function getLoggingSettings() { + return api("/settings/logging"); +} + +export function updateLoggingSettings(settings: { + mode: LoggingSettings["mode"]; + count: number; + days: number; +}) { + return api("/settings/logging", { method: "PUT", body: settings }); +} + +export function apiMessage(error: unknown) { + if (error instanceof ApiError) { + const suffix = error.requestId ? `(${tl("请求")} ${error.requestId})` : ""; + return `${error.message}${suffix}`; + } + if (error instanceof Error) return error.message; + return tl("请求未完成,检查服务状态后重试"); +} + +export function eventStreamURL(path: string, params?: URLSearchParams) { + const suffix = params?.toString(); + return `${path.startsWith("/api") ? path : `/api${path}`}${suffix ? `?${suffix}` : ""}`; +} diff --git a/web/src/components/DeviceCard.tsx b/web/src/components/DeviceCard.tsx new file mode 100644 index 0000000..491b75c --- /dev/null +++ b/web/src/components/DeviceCard.tsx @@ -0,0 +1,90 @@ +import { + Cellular3GRegular, Cellular4GRegular, Cellular5GRegular, CellularData1Regular, + RouterRegular, Wifi1Regular, +} from "@fluentui/react-icons"; +import type { DashboardDevice } from "../types"; +import { cx, signalBars, signalColor, isEC20Model } from "../lib/utils"; +import { StatusDot } from "./ui/StatusDot"; +import { useI18n } from "../lib/i18n"; + +const BAR_HEIGHTS = ["h-1/4", "h-2/4", "h-3/4", "h-full"]; + +export function DeviceCard({ device, onOpen }: { device: DashboardDevice; onOpen: (id: string) => void }) { + const { t } = useI18n(); + const mode = `${device.networkDuplex ? `${device.networkDuplex} ` : ""}${device.networkMode || ""}`.trim(); + const up = mode.toUpperCase(); + const has = mode.length > 0; + const NetIcon = device.vowifiActive ? Wifi1Regular + : !has ? CellularData1Regular + : up.includes("5G") || up.includes("NR") ? Cellular5GRegular + : up.includes("4G") || up.includes("LTE") ? Cellular4GRegular + : up.includes("3G") || up.includes("WCDMA") || up.includes("HSPA") || up.includes("UMTS") ? Cellular3GRegular + : CellularData1Regular; + const netColor = device.vowifiActive ? "text-emerald-500" + : !has ? "text-gray-400" + : up.includes("5G") || up.includes("NR") ? "text-purple-500" + : up.includes("4G") || up.includes("LTE") ? "text-blue-500" + : up.includes("3G") ? "text-orange-500" : "text-gray-400"; + const words = mode.split(/\s+/).filter(Boolean); + const second = words.length > 1 ? words[1] : words[0] || ""; + const isLte = second.toUpperCase() === "LTE"; + const bars = signalBars(device.signalDbm); + const brandImg = isEC20Model(device.model); + + return ( + + ); +} diff --git a/web/src/components/Disclaimer.tsx b/web/src/components/Disclaimer.tsx new file mode 100644 index 0000000..617f8db --- /dev/null +++ b/web/src/components/Disclaimer.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { cx } from "../lib/utils"; +import { useI18n } from "../lib/i18n"; +import { message } from "./ui/message"; +import * as api from "../api"; + +const PHRASES = { zh: "我同意并确认", en: "I agree and confirm" } as const; + +function WarningGlyph() { + return ( + + + + ); +} + +function Item({ index, children }: { index: number; children: React.ReactNode }) { + return ( +
+
+ {index} +
+

{children}

+
+ ); +} + +// 中文条款(新增:仅限高通模块检测/测试卡、禁止 MCC 460、仅限美国硬件企业开发商)。 +function ZhItems() { + return ( + <> + + 本软件(vocat)属于个人开发者业余时间开发的工具软件,支持高通模块调试,仅供技术研究、学习交流及企业内部测试使用。 + 严禁用于任何形式的商业出售或转售 + ,严禁作为生产环境的基础设施。 + + 本项目仅用于高通模块功能正常检测使用,仅限接入测试类卡片使用。 + + 禁止 MCC 460 卡片进行测试。 + + + 本软件面向使用配套设备进行高通模块调试的企业开发者提供。允许企业使用本软件对其设备进行调试,但 + 严禁商业出售或转售 + ;如发现在此范围外的违规使用, + 我们将会自动锁定软件以及拉黑您的卡片 EID。 + + + 使用者承诺将严格遵守所在国家或地区的相关法律法规。 + + 严禁将本软件用于电信诈骗、垃圾短信发送、非法网络代理、渗透测试等任何非法或违规场景 + + 。 + + + 本软件涉及底层 Modem 通信操作,可能包含未知的缺陷。对于因使用本软件引发的硬件损坏、通信资费异常、隐私泄露等直接或间接损失, + 由使用者自行承担所有责任。 + + + 一旦点击继续即表示无条件接受本协议。如果您拒绝,本软件将立即触发自毁与环境清理机制以确保设备安全。 + + + ); +} + +// English clauses (mirror of the Chinese items). +function EnItems() { + return ( + <> + + This software (vocat) is a utility built by an independent developer in their spare time. It supports Qualcomm + module debugging and is provided only for technical research, learning, and enterprise internal testing.{" "} + + It is strictly prohibited to sell or resell it commercially in any form + {" "} + or to use it as production infrastructure. + + + This project is intended solely for verifying the proper functioning of Qualcomm modules; only test-class + SIM cards may be connected. + + + + Testing with MCC 460 (China) SIM cards is strictly prohibited. + + + + This software is provided to enterprise developers who use supporting equipment to debug Qualcomm modules. + Enterprises may use this software to debug their own equipment, but{" "} + + commercial sale or resale is strictly prohibited + + ; if misuse outside this scope is detected,{" "} + + the software will be automatically locked and your card's EID will be blacklisted + + . + + + The user undertakes to strictly comply with the laws and regulations of their country or region.{" "} + + It is strictly prohibited to use this software for telecom fraud, spam messaging, illegal network + proxying, penetration testing, or any other illegal or non-compliant scenario + + . + + + This software involves low-level modem communication and may contain unknown defects.{" "} + The user bears all responsibility for any direct or indirect losses arising from its use, + including hardware damage, abnormal carrier charges, or privacy leakage. + + + Clicking continue constitutes unconditional acceptance of this agreement. If you decline, the software + will immediately trigger its self-destruct and environment cleanup mechanism to keep the device safe. + + + ); +} + +const OVERLAY_STYLE = + "display:flex;height:100vh;background:#0a0a0a;align-items:center;justify-content:center;" + + "font-size:24px;color:#ef4444;font-weight:bold;font-family:sans-serif;flex-direction:column;gap:16px;"; +const OVERLAY_ICON = + '' + + ''; + +// Disclaimer / EULA overlay shown after login (first run requires typing the +// phrase; subsequent periodic confirmations only require a click). +export function Disclaimer({ + firstTime, + onAgree, +}: { + firstTime: boolean; + onAgree: () => void; +}) { + const { t, lang } = useI18n(); + const zh = lang === "zh"; + const phrase = PHRASES[lang]; + const [typed, setTyped] = useState(""); + const canAgree = !firstTime || typed === phrase; + + function reject() { + message.warning(zh ? t("正在退出并清理软件...") : "Exiting and cleaning up..."); + api + .api("/system/uninstall", { method: "POST" }) + .catch(() => {}) + .finally(() => { + const text = zh ? t("软件已被卸载 / 服务已终止") : "Software uninstalled / service stopped"; + document.body.innerHTML = `
${OVERLAY_ICON}
${text}
`; + }); + } + + return ( +
+
+
+
+
+ +
+

+ {zh ? t("vocat 最终用户许可与免责声明") : "vocat End User License Agreement & Disclaimer"} +

+
+ {zh ? : } +
+
+ {firstTime ? ( +

+ {zh ? t("请输入") : "Please type"}「 + {phrase}」 + {zh ? t("以解锁按钮") : "to unlock the button"} +

+ ) : ( +

+ {zh ? t("本次为周期性确认,点击") : "Periodic confirmation. Click"}「 + {phrase}」 + {zh ? t("即可继续") : "to continue"} +

+ )} + {firstTime && ( +
+ setTyped(event.target.value)} + onPaste={(event) => event.preventDefault()} + autoComplete="off" + placeholder={zh ? `请输入:${phrase}` : `Please type: ${phrase}`} + className="w-full rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-center text-sm font-semibold outline-none transition-all placeholder-gray-400 focus:border-indigo-500 focus:ring-2 focus:ring-indigo-500/50 dark:border-gray-700 dark:bg-gray-800/80 dark:text-white dark:placeholder-gray-500 dark:focus:border-indigo-500" + /> +
+ )} +
+ + +
+
+
+
+
+ ); +} diff --git a/web/src/components/EChart.tsx b/web/src/components/EChart.tsx new file mode 100644 index 0000000..9c0246c --- /dev/null +++ b/web/src/components/EChart.tsx @@ -0,0 +1,27 @@ +import { useEffect, useRef } from "react"; +import * as echarts from "echarts"; + +// Thin React wrapper around an echarts instance (init once, setOption on change). +export function EChart({ option, className }: { option: unknown; className?: string }) { + const ref = useRef(null); + const chartRef = useRef(null); + + useEffect(() => { + if (!ref.current) return; + const chart = echarts.init(ref.current); + chartRef.current = chart; + const ro = new ResizeObserver(() => chart.resize()); + ro.observe(ref.current); + return () => { + ro.disconnect(); + chart.dispose(); + chartRef.current = null; + }; + }, []); + + useEffect(() => { + if (chartRef.current && option) chartRef.current.setOption(option as echarts.EChartsOption, true); + }, [option]); + + return
; +} diff --git a/web/src/components/devices/AtLogEntry.tsx b/web/src/components/devices/AtLogEntry.tsx new file mode 100644 index 0000000..c299b3e --- /dev/null +++ b/web/src/components/devices/AtLogEntry.tsx @@ -0,0 +1,52 @@ +import { cx } from "../../lib/utils"; + +export interface AtLogItem { + ts: number; + cmd: string; + ok: boolean; + response: string; +} + +export function AtLogEntry({ item }: { item: AtLogItem }) { + const time = new Date(item.ts).toLocaleTimeString(); + return ( +
+
+
+
{item.cmd}
+
{time}
+
+
+
+
+
{item.response}
+
+ {time} +
+
+
+
+ ); +} + +export function AtTypingBubble({ label }: { label: string }) { + return ( +
+
+
+
+
+
+
+ {label} +
+
+ ); +} diff --git a/web/src/components/devices/CandidateRow.tsx b/web/src/components/devices/CandidateRow.tsx new file mode 100644 index 0000000..7bcfedd --- /dev/null +++ b/web/src/components/devices/CandidateRow.tsx @@ -0,0 +1,45 @@ +import { Button } from "../ui"; +import type { OperatorCandidate } from "./types"; +import { useI18n } from "../../lib/i18n"; + +function ratsText(c: OperatorCandidate): string { + const list = (c.rats || []).filter(Boolean) as string[]; + return list.length ? list.map((r) => r.toUpperCase()).join(" / ") : "--"; +} + +export function CandidateRow({ candidate, onLock }: { candidate: OperatorCandidate; onLock: (c: OperatorCandidate) => void }) { + const { t } = useI18n(); + const c = candidate; + const forbidden = c.status === "forbidden"; + return ( +
{ if (!forbidden) onLock(c); }} + > +
+
+ {c.operatorName || c.shortName || t("未知网络")}{" "} + {c.status === "current" ? ( + + {t("当前")} + + ) : c.status === "forbidden" ? ( + + {t("禁用")} + + ) : null} +
+
+ {c.plmn} • {ratsText(c)} +
+
+
+ +
+
+ ); +} diff --git a/web/src/components/devices/CardPolicyPanel.tsx b/web/src/components/devices/CardPolicyPanel.tsx new file mode 100644 index 0000000..cfafa3d --- /dev/null +++ b/web/src/components/devices/CardPolicyPanel.tsx @@ -0,0 +1,87 @@ +import { CardUiRegular } from "@fluentui/react-icons"; +import { Tag } from "../ui"; +import { PolicySwitchCard } from "./PolicySwitchCard"; +import { useCardPolicyToggles } from "./useCardPolicyToggles"; +import { enableVoWiFi, disableVoWiFi, setFlightMode } from "./deviceActions"; +import type { CardPolicy } from "../../types"; +import { useI18n } from "../../lib/i18n"; + +export interface CardPolicyPanelProps { + deviceId: string; + iccid?: string; + policy: CardPolicy | null; + deviceOnline: boolean; + onPolicyChanged: () => void; +} + +export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) { + const { t } = useI18n(); + const operable = deviceOnline && !!iccid; + const flags = policy + ? { vowifiEnabled: policy.vowifiEnabled, airplaneEnabled: policy.airplaneEnabled } + : null; + + const toggles = useCardPolicyToggles(flags, { + applyVoWiFi: (value) => (deviceId ? (value ? enableVoWiFi(deviceId) : disableVoWiFi(deviceId)) : Promise.resolve({ ok: false })), + applyAirplane: (value) => (deviceId ? setFlightMode(deviceId, value) : Promise.resolve({ ok: false })), + onChanged: onPolicyChanged, + }); + + const sourceLabel = policy ? (policy.source === "user" ? t("手动设置") : t("自动默认")) : ""; + const { local } = toggles; + + return ( +
+
+
+ +
+
+
{t("卡策略")}
+
{t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}
+
+
+ {!iccid ? ( +
{t("设备尚未识别到 SIM 卡 ICCID,策略不可操作")}
+ ) : null} + {iccid && !deviceOnline ? ( +
+ {t("设备离线,策略仅展示,切换操作已禁用")} +
+ ) : null} + {iccid ? ( +
+
+
+
{t("当前卡 ICCID")}
+
{iccid}
+
+ {sourceLabel ? {sourceLabel} : null} +
+
+ + +
+
+ ) : null} +
+ ); +} diff --git a/web/src/components/devices/CarrierWebsheetDialog.tsx b/web/src/components/devices/CarrierWebsheetDialog.tsx new file mode 100644 index 0000000..84db80c --- /dev/null +++ b/web/src/components/devices/CarrierWebsheetDialog.tsx @@ -0,0 +1,128 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { api } from "../../api"; +import { Modal } from "../ui"; +import { useI18n } from "../../lib/i18n"; + +export interface CarrierWebsheet { + id?: string; + title?: string; + embedUrl?: string; +} + +export interface CarrierWebsheetDialogProps { + open: boolean; + websheet: CarrierWebsheet | null; + onClose: () => void; + onDone: () => void; +} + +export function CarrierWebsheetDialog({ open, websheet, onClose, onDone }: CarrierWebsheetDialogProps) { + const { t } = useI18n(); + const [loaded, setLoaded] = useState(false); + const doneRef = useRef(false); + const embedUrl = websheet?.embedUrl || ""; + const token = useMemo(() => { + if (!embedUrl) return ""; + try { + return new URL(embedUrl, window.location.origin).searchParams.get("token") || ""; + } catch { + return ""; + } + }, [embedUrl]); + + useEffect(() => { + setLoaded(false); + }, [websheet?.id]); + + useEffect(() => { + function shouldIgnore(callback: unknown): boolean { + if (!callback || typeof callback !== "object") return true; + const c = callback as Record; + const k = String(c.event ?? c.method ?? c.resultCode ?? "").toLowerCase(); + return k ? !k.includes("phoneservicesaccountstatuschanged") : true; + } + function isValid(data: unknown): data is { type: string; token?: string; callback?: unknown } { + if (!data || typeof data !== "object") return false; + const d = data as Record; + if (d.type !== "vohive-websheet-callback") return false; + const t = typeof d.token === "string" ? d.token : ""; + return !(token && t && t !== token); + } + async function relay(callback: unknown) { + const id = websheet?.id; + if (!id || !callback || typeof callback !== "object") return; + try { + await api(`/websheets/${id}/callback`, { method: "POST", body: callback }); + } catch (e) { + console.error("[CarrierWebsheetDialog] relay callback failed:", e); + } + } + async function done() { + if (doneRef.current) return; + doneRef.current = true; + try { + const id = websheet?.id; + if (id) { + try { + await api(`/websheets/${id}/done`, { method: "POST" }); + } catch (e) { + console.error("[CarrierWebsheetDialog] complete websheet failed:", e); + } + } + onDone(); + onClose(); + } finally { + doneRef.current = false; + } + } + function handle(data: unknown) { + if (!open || !isValid(data)) return; + if (shouldIgnore(data.callback)) void done(); + else void relay(data.callback); + } + const onMessage = (e: MessageEvent) => handle(e.data); + const onStorage = (e: StorageEvent) => { + if (e.key !== "vohive-websheet-complete" || !e.newValue) return; + try { + handle(JSON.parse(e.newValue)); + } catch { + /* ignore */ + } + }; + window.addEventListener("message", onMessage); + window.addEventListener("storage", onStorage); + let channel: BroadcastChannel | null = null; + try { + channel = new BroadcastChannel("vohive-websheet"); + channel.onmessage = (e) => handle(e.data); + } catch { + channel = null; + } + return () => { + window.removeEventListener("message", onMessage); + window.removeEventListener("storage", onStorage); + channel?.close(); + }; + }, [open, websheet?.id, token, onClose, onDone]); + + return ( + +
+ {!loaded ? ( +
+ {t("加载中...")} +
+ ) : null} + {embedUrl ? ( +