12 Commits
Author SHA1 Message Date
Meng MengandGitHub 296f963885 Merge pull request #3 from MengMengCode/dependabot/go_modules/go_modules-a3c8a40308
build(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in the go_modules group across 1 directory
2026-08-12 02:38:34 +08:00
dependabot[bot]andGitHub 1b9546a73d build(deps): bump golang.org/x/crypto
Bumps the go_modules group with 1 update in the / directory: [golang.org/x/crypto](https://github.com/golang/crypto).


Updates `golang.org/x/crypto` from 0.41.0 to 0.52.0
- [Commits](https://github.com/golang/crypto/compare/v0.41.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.52.0
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-11 18:35:30 +00:00
MengMengCode 22487dbb1f Add PC/SC backend support for USB SIM readers
- Implemented a new `unsupportedBackend` in `backend_stub.go` to handle unsupported platforms.
- Created a `Service` struct in `service.go` to manage interactions with smart cards, including session management and identity reading.
- Added methods for reading identity, checking readiness, and authenticating with USIM applications.
- Introduced a `scriptedCard` for testing purposes in `service_test.go` to simulate card responses.
- Defined necessary types and error handling in `types.go` for better clarity and usability.
- Developed a `PCSCAdapter` in `vowifi/pcsc_adapter.go` to integrate PC/SC service with Wi-Fi calling functionalities.
- Added a new SVG icon for USB SIM readers in `public/sim-reader.svg`.
- Implemented tests to ensure correct functionality and error handling in various scenarios.
2026-08-12 02:33:32 +08:00
Meng MengandGitHub f9bb38aabe Add star history badge to README 2026-08-11 23:52:46 +08:00
MengMengCode 5bb5808706 fix: update expected local time format in WeCom SMS values test 2026-08-11 23:06:48 +08:00
Meng MengandGitHub d70937cc47 Merge pull request #2 from zAhYAng/feat/wecom-notifications
feat: add WeCom message push notifications
2026-08-11 22:56:19 +08:00
zAhYAng eab658dc90 fix: polish WeCom notification settings 2026-08-11 22:28:36 +08:00
zAhYAng 962c58fdd1 feat: add WeCom notification settings 2026-08-11 21:41:43 +08:00
zAhYAng 7b2e005b37 feat: dispatch WeCom notifications 2026-08-11 21:39:53 +08:00
zAhYAng f1e70ecee5 feat: configure WeCom notifications 2026-08-11 21:38:09 +08:00
zAhYAng f012c556e9 feat: add WeCom payload renderer 2026-08-11 21:33:38 +08:00
zAhYAng ab8bbbc1ed docs: plan WeCom notifications and worktree setup 2026-08-11 21:19:02 +08:00
59 changed files with 3032 additions and 129 deletions
+1
View File
@@ -55,3 +55,4 @@ Thumbs.db
# ---- Claude Code / agent ----
.claude/
.worktrees/
+2
View File
@@ -316,3 +316,5 @@ cd web && npm run build
## License
See [LICENSE](LICENSE).
[![MengMengCode/VoCat Star History](https://mengmeng.meteor-history.com/api/embed/MengMengCode/VoCat.svg?sig=sdeXRVxAoY3yLWgXL7JViY2USYIN3t9neJ6ScPvgUAo&theme=light&style=xkcd&color=dd4528&background=ffffff&textColor=000000&width=900&height=600&lineWidth=3&showTitle=true&showLegend=true&showDots=false&v=0.0.14)](https://meteor-history.com)
+70 -4
View File
@@ -27,6 +27,7 @@ import (
"vocat/internal/extensions"
"vocat/internal/httpsmode"
"vocat/internal/loghub"
"vocat/internal/pcsc"
"vocat/internal/server"
"vocat/internal/store"
"vocat/internal/update"
@@ -184,7 +185,8 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
return err
}
deviceManager, err := device.NewManager(device.Options{})
cardReaders := pcsc.New()
deviceManager, err := device.NewManager(device.Options{CardReaders: cardReaders})
if err != nil {
return fmt.Errorf("create device manager: %w", err)
}
@@ -220,6 +222,7 @@ func run(logger *slog.Logger, logs *loghub.Hub) error {
logger,
database,
deviceManager,
cardReaders,
)
if err != nil {
return fmt.Errorf("configure VoWiFi runtime: %w", err)
@@ -362,6 +365,12 @@ func configureDeviceBackends(
if mapErr != nil {
continue
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
if err := manager.SetSIMPin(entry.ID, config.SIMPIN); err != nil {
logger.Warn("configure USB SIM reader", "device_id", config.ID, "error", err)
}
continue
}
if err := manager.SetBackend(entry.ID, config.DeviceBackend); err != nil {
logger.Warn("configure device backend", "device_id", config.ID, "backend", config.DeviceBackend, "error", err)
}
@@ -384,6 +393,9 @@ func restoreDefaultCellularRadios(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if config.VoWiFiEnabled {
continue
}
@@ -431,6 +443,9 @@ func restoreConfiguredCellularData(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
if !config.NetworkEnabled || config.VoWiFiEnabled {
continue
}
@@ -482,6 +497,9 @@ func disableAllDeveloperCellularData(
}
mapper := integration.ATMapper{Store: database, Devices: manager}
for _, config := range configs {
if config.DeviceType == store.DeviceTypeUSBSIMReader {
continue
}
entry, err := mapper.Get(config.ID)
if err != nil {
continue
@@ -536,12 +554,13 @@ func configureVoWiFiRuntime(
logger *slog.Logger,
database *store.Store,
deviceManager *device.Manager,
cardReaders *pcsc.Service,
) (*vowifiruntime.Manager, error) {
mapper := integration.ATMapper{
Store: database,
Devices: deviceManager,
}
adapter, err := vowifi.NewEC20Adapter(mapper, vowifi.EC20AdapterOptions{
ec20Adapter, 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,
@@ -556,6 +575,16 @@ func configureVoWiFiRuntime(
if err != nil {
return nil, err
}
pcscAdapter, err := vowifi.NewPCSCAdapter(cardReaders, func(ctx context.Context, deviceID string) (pcsc.Selector, string, error) {
config, resolveErr := database.Device(ctx, strings.TrimSpace(deviceID))
if resolveErr != nil {
return pcsc.Selector{}, "", resolveErr
}
return pcsc.Selector{USBPath: config.USBPath, ReaderName: config.ControlDevice}, config.SIMPIN, nil
})
if err != nil {
return nil, err
}
projector := integration.StateProjector{
Store: database,
Devices: mapper,
@@ -568,6 +597,10 @@ func configureVoWiFiRuntime(
if err != nil {
return nil, fmt.Errorf("load device %q VoWiFi config: %w", deviceID, err)
}
adapter := vowifiDeviceAdapter(ec20Adapter)
if deviceConfig.DeviceType == store.DeviceTypeUSBSIMReader {
adapter = pcscAdapter
}
return newVoWiFiOrchestrator(deviceConfig, database, adapter)
},
})
@@ -601,10 +634,16 @@ func configureVoWiFiRuntime(
return manager, nil
}
type vowifiDeviceAdapter interface {
vowifi.SIMIdentityReader
vowifi.AKAProvider
vowifi.RadioController
}
func newVoWiFiOrchestrator(
deviceConfig store.Device,
database *store.Store,
adapter *vowifi.EC20Adapter,
adapter vowifiDeviceAdapter,
) (*vowifi.Orchestrator, error) {
apn := deviceConfig.APN
if apn == "" {
@@ -734,9 +773,18 @@ func provisionDiscoveredDevices(
candidate := discovered.Candidate
backend := "at"
control := candidate.ATPort.OpenPath()
deviceType := store.DeviceTypePCIeEC20EC25
esimTransport := backend
if candidate.QMIControl != "" {
backend = "qmi"
control = candidate.QMIControl
esimTransport = backend
}
if candidate.HardwareKind == pcsc.HardwareKind {
backend = "pcsc"
control = candidate.ReaderName
deviceType = store.DeviceTypeUSBSIMReader
esimTransport = "pcsc"
}
name := candidate.Product
if name == "" || strings.EqualFold(name, "Android") {
@@ -745,6 +793,7 @@ func provisionDiscoveredDevices(
if err := database.UpsertDevice(ctx, store.Device{
ID: discovered.ID,
Name: name,
DeviceType: deviceType,
Interface: candidate.NetworkInterface,
ControlDevice: control,
ATPort: candidate.ATPort.OpenPath(),
@@ -755,7 +804,7 @@ func provisionDiscoveredDevices(
StopBits: 1,
Parity: "none",
DeviceBackend: backend,
ESIMTransport: backend,
ESIMTransport: esimTransport,
NetworkEnabled: false,
SMSEnabled: true,
VoWiFiEnabled: true,
@@ -931,6 +980,7 @@ func reconcileCardPolicies(
manager *device.Manager,
vowifiManager *vowifiruntime.Manager,
) {
observedCards := make(map[string]string)
reconcile := func() {
policies, policyListErr := database.ListCardPolicies(ctx)
if policyListErr == nil {
@@ -953,12 +1003,26 @@ func reconcileCardPolicies(
for _, config := range configs {
entry, mapErr := mapper.Get(config.ID)
if mapErr != nil || entry.Snapshot == nil {
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
_, _ = vowifiManager.RequestReconnect(config.ID)
}
observedCards[config.ID] = "missing"
}
continue
}
iccid := strings.TrimSpace(entry.Snapshot.ICCID)
if iccid == "" {
if config.DeviceType == store.DeviceTypeUSBSIMReader && observedCards[config.ID] != "missing" {
if state, stateErr := vowifiManager.State(config.ID); stateErr == nil && state.ICCID != "" {
_, _ = vowifiManager.RequestReconnect(config.ID)
}
observedCards[config.ID] = "missing"
}
continue
}
previousObserved := observedCards[config.ID]
observedCards[config.ID] = iccid
policy, policyErr := database.CardPolicy(ctx, iccid)
if policyErr != nil {
continue
@@ -1001,6 +1065,8 @@ func reconcileCardPolicies(
_, _ = vowifiManager.RequestEnabled(config.ID, true)
case state.ICCID != "" && !strings.EqualFold(strings.TrimSpace(state.ICCID), iccid):
_, _ = vowifiManager.RequestReconnect(config.ID)
case config.DeviceType == store.DeviceTypeUSBSIMReader && previousObserved == "missing":
_, _ = vowifiManager.RequestReconnect(config.ID)
}
continue
}
@@ -0,0 +1,383 @@
# 企业微信消息推送实现计划
> **面向 AI 代理的工作者:** 必需子技能:使用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
**目标:** 增加可配置 JSON 请求模板的企业微信 Webhook 通知通道,向新短信和自动任务结果发送消息。
**架构:** 新建专注的企业微信通知模块,统一构建事件变量、JSON 安全替换、Webhook POST 和 `errcode` 响应判定。设置 API 将 `wecom` 纳入白名单、保密 URL 与连通性测试;短信和自动任务分发器只增加该通道分支。前端在现有通知设置表单中新增企业微信页签和请求体编辑器。
**技术栈:** Go 1.25、标准库 `net/http``encoding/json`、SQLite 通知设置、React、TypeScript、Vite。
---
## 文件结构
- 创建:`internal/server/wecom_notification.go`,渲染企业微信 JSON 模板、创建安全 HTTP 请求并判定企业微信响应。
- 创建:`internal/server/wecom_notification_test.go`,覆盖 JSON 转义、模板拒绝和企业微信响应失败。
- 修改:`internal/server/settings_api.go`,登记 `wecom` 配置字段、启用连通性测试并调用企业微信发送器。
- 修改:`internal/server/settings_api_test.go`,验证企业微信配置 API、敏感 URL 与测试路径。
- 修改:`internal/store/settings.go`,将 `wecom.urls` 注册为敏感字段。
- 修改:`internal/server/sms_notifications.go`,将新短信事件接入企业微信通道。
- 修改:`internal/server/sms_notifications_test.go`,覆盖企业微信短信配置要求和变量数据。
- 修改:`internal/server/automatic_task_notifications.go`,将自动任务结果接入企业微信通道。
- 修改:`web/src/types.ts`,扩展通知设置类型。
- 修改:`web/src/components/settings/model.ts`,增加企业微信表单、默认模板、读取和提交映射。
- 修改:`web/src/components/settings/PushTabs.tsx`,新增企业微信配置界面。
- 修改:`web/src/pages/SettingsPage.tsx`,增加页签、测试状态与测试请求。
### 任务 1:企业微信模板与响应判定
**文件:**
- 创建:`internal/server/wecom_notification_test.go`
- 创建:`internal/server/wecom_notification.go`
- [ ] **步骤 1:编写失败的模板与响应测试**
```go
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
payload, err := renderWecomPayload(
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
wecomTemplateValues{"message": "quote: \\"\\nline", "number": "+447386"},
)
if err != nil { t.Fatal(err) }
if got := string(payload); got != `{"msgtype":"text","text":{"content":"quote: \\"\\nline","number":"+447386"}}` {
t.Fatalf("payload = %s", got)
}
}
func TestRenderWecomPayloadRejectsUnknownVariableAndNonObject(t *testing.T) {
for _, template := range []string{`{"text":{{unknown}}}`, `[]`} {
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
t.Fatalf("template %q was accepted", template)
}
}
}
func TestValidateWecomResponseRejectsProviderError(t *testing.T) {
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":40058,"errmsg":"invalid"}`)); !errors.Is(err, errProviderRejected) {
t.Fatalf("error = %v", err)
}
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:FAIL,提示 `renderWecomPayload``wecomTemplateValues``validateWecomResponse` 未定义。
- [ ] **步骤 3:实现最少的模板与响应代码**
`internal/server/wecom_notification.go` 中定义受支持变量列表,先用 `json.Marshal` 编码每个字符串,再替换精确的 `{{name}}` 标记;若保留任何 `{{``}}`,或者 `json.Unmarshal` 后不是非空 `map[string]json.RawMessage`,返回错误。响应处理必须要求 HTTP 2xx、可解析 JSON,且 `errcode` 为零。
```go
type wecomTemplateValues map[string]string
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
for _, name := range wecomTemplateVariableNames {
encoded, _ := json.Marshal(values[name])
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
}
if strings.Contains(template, "{{") || strings.Contains(template, "}}") {
return nil, errors.New("wecom.payload_template contains an unsupported variable")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
}
return []byte(template), nil
}
func validateWecomResponse(status int, body []byte) error {
var result struct { ErrCode int `json:"errcode"` }
if status < http.StatusOK || status >= http.StatusMultipleChoices || json.Unmarshal(body, &result) != nil || result.ErrCode != 0 {
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
}
return nil
}
func wecomTestValues(now time.Time) wecomTemplateValues {
return wecomTemplateValues{
"event": "test", "title": "vocat", "message": "vocat notification test",
"timestamp": now.UTC().Format(time.RFC3339),
}
}
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
if err != nil { return err }
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil { return err }
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 WeCom notification request: %w", err) }
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
response, err := client.Do(request)
if err != nil { return fmt.Errorf("send WeCom notification: %w", err) }
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10)); response.Body.Close()
if readErr != nil { return fmt.Errorf("read WeCom response: %w", readErr) }
if err := validateWecomResponse(response.StatusCode, body); err != nil { return err }
}
return nil
}
```
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestRenderWecomPayload|TestValidateWecomResponse' -count=1`
预期:PASS。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/wecom_notification.go internal/server/wecom_notification_test.go && git commit -m "feat: add WeCom payload renderer"`
预期:创建包含模板渲染和响应判定的提交。若 Git 作者身份仍未配置,停止提交但保留已验证的工作区改动,不自行设置身份。
### 任务 2:设置 API 与敏感 Webhook URL
**文件:**
- 修改:`internal/server/settings_api_test.go`
- 修改:`internal/store/settings.go`
- 修改:`internal/server/settings_api.go`
- [ ] **步骤 1:编写失败的 API 测试**
```go
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
body := `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"],"payload_template":"{\\\"msgtype\\\":\\\"text\\\",\\\"text\\\":{\\\"content\\\":{{message}}}}"}}`
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", body)
if recorder.Code != http.StatusOK { t.Fatalf("status = %d", recorder.Code) }
if bytes.Contains(recorder.Body.Bytes(), []byte("key=secret")) { t.Fatal("response leaked webhook URL") }
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
if err != nil || !bytes.Contains(stored.Config, []byte("key=secret")) { t.Fatalf("stored = %s, err = %v", stored.Config, err) }
}
func TestWecomNotificationSettingsRejectMalformedTemplate(t *testing.T) {
test := newSettingsAPITest(t)
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", `{"wecom":{"enabled":true,"urls":["https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=x"],"payload_template":"[]"}}`)
if recorder.Code != http.StatusBadRequest { t.Fatalf("status = %d", recorder.Code) }
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings' -count=1`
预期:FAIL,设置 API 返回 `invalid_notification_channel`
- [ ] **步骤 3:实现 API 契约、保存和测试端点**
`notificationChannels` 中加入 `wecom`,在 `notificationFields` 中登记 `urls: strings``payload_template: wecom_template`。将 `urls` 加入 `DefaultNotificationSensitiveFields("wecom")`。在字段验证中对 `wecom_template` 调用 `renderWecomPayload`,以默认测试变量确认模板会生成对象;在 `validateNotificationTestConfig``handleNotificationTest` 和发送分支中支持 `wecom`
```go
"wecom": {"urls": "strings", "payload_template": "wecom_template"},
case "wecom":
return []string{"urls"}
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
```
将上段 `payload_template` 的字段类型实现为 `wecom_template`,避免只按普通字符串检查:
```go
case "wecom_template":
var template string
if err := json.Unmarshal(raw, &template); err != nil || len(template) > 32768 {
return fmt.Errorf("%s must be a template string", field)
}
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
return err
case "wecom":
if len(configStrings(config, "urls")) == 0 || configString(config, "payload_template") == "" {
return errors.New("wecom.urls and wecom.payload_template are required")
}
```
测试消息的变量必须为 `event: "test"``title: "vocat"``message: "vocat notification test"` 和当前 UTC RFC3339 时间;它应经过与生产消息完全相同的渲染和发送路径。
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestWecomNotificationSettings|TestNotificationSettingsAlwaysReturns' -count=1`
预期:PASSGET/PUT 响应不会泄露 `key`,但数据库保留原 URL。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/settings_api.go internal/server/settings_api_test.go internal/store/settings.go && git commit -m "feat: configure WeCom notifications"`
预期:创建设置 API 与敏感配置提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 3:接入短信与自动任务分发
**文件:**
- 修改:`internal/server/sms_notifications_test.go`
- 修改:`internal/server/sms_notifications.go`
- 修改:`internal/server/automatic_task_notifications.go`
- [ ] **步骤 1:编写失败的事件变量测试**
```go
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
message := smsNotification{DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20", Number: "+447386", Time: time.Unix(1700000000, 0), Content: "hello"}
values := wecomSMSValues(message)
if values["event"] != "sms.received" || values["content"] != "hello" || values["device_label"] != "EC20" {
t.Fatalf("values = %#v", values)
}
}
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
values := wecomAutomaticTaskValues(automaticTaskNotification{Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1700000000, 0)})
if values["event"] != "automatic_task.completed" || values["message"] != "任务已完成" || values["number"] != "" {
t.Fatalf("values = %#v", values)
}
}
```
- [ ] **步骤 2:运行测试验证失败**
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues' -count=1`
预期:FAIL,两个事件变量构建函数未定义。
- [ ] **步骤 3:实现分发接入**
在企业微信模块中实现 `wecomSMSValues``wecomAutomaticTaskValues`,填充全部已声明变量,短信专属字段在自动任务事件中设为空字符串。然后将 `wecom` 加入以下分发列表与 switch
```go
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
```
```go
channels := []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"}
for _, channel := range channels {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) { continue }
if err != nil { s.logger.Warn("read automatic task notification setting", "channel", channel, "error", err); continue }
var config map[string]any
if err := json.Unmarshal(setting.Config, &config); err != nil { s.logger.Warn("decode automatic task notification setting", "channel", channel, "error", err); continue }
if err := sendAutomaticTaskNotification(ctx, channel, config, notification); err != nil { s.logger.Warn("send automatic task notification", "channel", channel, "task_id", task.ID, "error", err) }
}
case "wecom":
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
```
保持既有游标、错误限流日志和其他通道的行为不变。
- [ ] **步骤 4:运行测试验证通过**
运行:`go test ./internal/server -run 'TestWecomSMSValues|TestWecomAutomaticTaskValues|TestValidateSMSNotificationConfig' -count=1`
预期:PASS`validateSMSNotificationConfig` 也接受包含有效 URL 和模板的 `wecom` 配置。
- [ ] **步骤 5:提交本任务**
运行:`git add internal/server/wecom_notification.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go && git commit -m "feat: dispatch WeCom notifications"`
预期:创建两类事件分发接入提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 4:企业微信配置界面
**文件:**
- 修改:`web/src/types.ts`
- 修改:`web/src/components/settings/model.ts`
- 修改:`web/src/components/settings/PushTabs.tsx`
- 修改:`web/src/pages/SettingsPage.tsx`
- [ ] **步骤 1:扩展前端类型和表单映射**
`NotificationSettings``NotifyForms` 中增加 `wecom`。新增以下表单类型和默认请求体;URL 数组保持一项一个输入行的既有 `UrlListEditor` 约定。
```ts
export interface WecomForm {
enabled: boolean;
urls: string[];
payloadTemplate: string;
}
const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": { "content": {{message}} }
}`;
```
`formsFromNotifications` 读取 `payload_template``buildNotificationsPayload` 输出 `payload_template`,测试请求则修剪并移除空 URL。
- [ ] **步骤 2:实现企业微信页签与测试请求**
`PushTabs.tsx` 增加 `WecomTab`,显示启用开关、`UrlListEditor`、JSON `Textarea` 和变量说明。URL 列表文案必须明确“每个 Webhook URL 单独一行,点击添加 URL 增加”,不得提示使用分隔符。
```tsx
<Field label={t("JSON 请求体模板")} hint={<span> JSON 使 <code>{'{{message}}'}</code></span>}>
<Textarea value={value.payloadTemplate} onChange={(event) => onChange({ payloadTemplate: event.target.value })} disabled={off} rows={12} />
</Field>
```
`SettingsPage.tsx` 增加 `testingWecom``onTestWecom`、企业微信页签与组件渲染。测试请求使用 `POST /settings/notifications/wecom/test` 和企业微信表单 payload;成功与失败消息沿用现有通知测试模式。
- [ ] **步骤 3:运行前端构建验证**
运行:`npm run build`
工作目录:`web`
预期:Vite 类型检查与生产构建均以退出码 0 完成。
- [ ] **步骤 4:提交本任务**
运行:`git add web/src/types.ts web/src/components/settings/model.ts web/src/components/settings/PushTabs.tsx web/src/pages/SettingsPage.tsx && git commit -m "feat: add WeCom notification settings"`
预期:创建企业微信设置 UI 提交;作者身份未配置时遵循任务 1 的处理方式。
### 任务 5:完整验证
**文件:**
- 修改:`internal/server/wecom_notification.go`
- 修改:`internal/server/wecom_notification_test.go`
- 修改:`internal/server/settings_api.go`
- 修改:`internal/server/settings_api_test.go`
- 修改:`internal/store/settings.go`
- 修改:`internal/server/sms_notifications.go`
- 修改:`internal/server/sms_notifications_test.go`
- 修改:`internal/server/automatic_task_notifications.go`
- 修改:`web/src/types.ts`
- 修改:`web/src/components/settings/model.ts`
- 修改:`web/src/components/settings/PushTabs.tsx`
- 修改:`web/src/pages/SettingsPage.tsx`
- [ ] **步骤 1:格式化 Go 代码**
运行:`gofmt -w internal/server/wecom_notification.go internal/server/wecom_notification_test.go internal/server/settings_api.go internal/server/settings_api_test.go internal/server/sms_notifications.go internal/server/sms_notifications_test.go internal/server/automatic_task_notifications.go internal/store/settings.go`
预期:所有修改的 Go 文件采用项目标准格式。
- [ ] **步骤 2:运行前端生产构建**
运行:`npm run build`
工作目录:`web`
预期:退出码 0,并生成 `web/dist` 供 Go 的嵌入资源使用。
- [ ] **步骤 3:运行后端回归测试**
运行:`go test ./...`
预期:所有目标包通过,无失败测试;`cmd/vocat``web` 包从步骤 2 生成的 `web/dist` 读取嵌入资源。
- [ ] **步骤 4:检查最终变更**
运行:`git diff --check && git status --short`
预期:无空白错误;变更仅限企业微信通知、其测试与设计/计划文档。
@@ -0,0 +1,55 @@
# 企业微信消息推送设计
## 目标
新增独立的 `wecom` 通知通道,通过企业微信“消息推送(原群机器人)”Webhook 推送新收到的短信和自动任务执行结果。外部 API 契约与既有通知通道保持一致。
## 配置模型
`wecom` 配置包含:
- `enabled`:是否启用通道。
- `urls`:一个或多个企业微信消息推送 Webhook URL。Web 设置页将每个 URL
显示为独立输入行,通过“添加 URL”按钮新增输入行、通过删除按钮移除输入行;
不使用逗号、空格或换行分隔多个 URL。
- `payload_template`:完整 JSON 请求体模板。
Webhook URL 含有企业微信访问密钥,必须作为敏感配置存储、在读取接口中脱敏,并在日志和错误信息中避免泄露。URL 沿用现有出站 URL 校验与 SSRF 防护。
## 模板语义
用户在 Web 设置页编辑完整 JSON 请求体,以选择企业微信支持的任意消息格式,例如 `text``markdown``news``template_card`
模板变量仅能作为 JSON 值出现,服务端使用 JSON 编码后的字符串替换,调用方不得在变量外添加引号。示例:
```json
{
"msgtype": "text",
"text": {
"content": {{message}}
}
}
```
可用变量:
- 通用:`{{event}}``{{title}}``{{message}}``{{timestamp}}`
- 短信事件:`{{content}}``{{number}}``{{device_id}}``{{device_name}}``{{device_label}}``{{time}}`
自动任务使用通用变量;短信专属变量在自动任务中替换为空字符串。模板渲染后必须为非空 JSON 对象,不得保留模板变量;无效模板在保存和测试时拒绝。
## 发送流程
短信分发器为 `wecom` 维护独立游标,发送失败不会阻塞其他通知渠道。自动任务完成后,和 Telegram、Bark、邮件、PushPlus、通用 Webhook 一样,向已启用的 `wecom` 通道发送结果。
发送器逐一 POST 渲染后的 JSON 到所有配置 URL,使用现有受限 HTTP 客户端。除 HTTP 2xx 外,企业微信返回 JSON 的 `errcode` 非零也视为服务商拒绝。
## Web 与 API
设置 API 将 `wecom` 加入已知通道和配置字段白名单,并提供 `POST /api/settings/notifications/wecom/test`。Web 设置页新增“企业微信”页签、启用开关、逐行编辑的 Webhook URL 列表、JSON 模板编辑器和测试按钮。
默认模板使用 `text` 消息,发送一条可辨识的测试内容。
## 验证
后端测试覆盖:配置字段验证、模板的 JSON 转义和拒绝无效模板、企业微信请求载荷、非零 `errcode` 失败处理、通知设置 API 读写与敏感 Webhook URL 保留。前端构建用于验证新增表单与类型契约。
+4 -2
View File
@@ -3,17 +3,19 @@ module vocat
go 1.25.0
require (
github.com/ElMostafaIdrassi/goscard v1.0.0
github.com/coder/websocket v1.8.15
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.41.0
golang.org/x/crypto v0.52.0
golang.org/x/sys v0.47.0
golang.org/x/term v0.34.0
golang.org/x/term v0.43.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/ebitengine/purego v0.8.2 // 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
+10 -6
View File
@@ -1,3 +1,5 @@
github.com/ElMostafaIdrassi/goscard v1.0.0 h1:RDG5QrqrQBUoi5MkzM4zILdYf8qDn62daYZszqvdgx0=
github.com/ElMostafaIdrassi/goscard v1.0.0/go.mod h1:uGOakQe2fFlW2cVlr9cv6x07uelrf0j0aKPbR7jGgfg=
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
@@ -6,6 +8,8 @@ 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/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
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=
@@ -18,12 +22,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
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=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
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=
@@ -33,8 +37,8 @@ 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/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=
+3
View File
@@ -274,6 +274,9 @@ func (manager *Manager) SetFlight(
if err := manager.validateActive(id, state); err != nil {
return FlightResult{}, err
}
if manager.candidateFor(state).HardwareKind == "pcsc" {
return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil
}
client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
if err != nil {
manager.setResult(id, state, nil, err)
+121 -26
View File
@@ -10,6 +10,7 @@ import (
"vocat/internal/i18n"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
// eUICC / eSIM (LPA, SGP.22) access over the modem's AT+CSIM APDU passthrough.
@@ -190,9 +191,11 @@ func parseCSIM(response modem.Response) ([]byte, int, error) {
// euiccChannel is an open logical channel to the eUICC's ISD-R.
type euiccChannel struct {
manager *Manager
id string
channel int
manager *Manager
id string
channel int
pcscSession *pcsc.Session
resetOnClose bool
}
// csimAPDUTimeout bounds a single AT+CSIM exchange. Loading a BoundProfilePackage
@@ -264,6 +267,14 @@ func (manager *Manager) openEuiccOnce(ctx context.Context, id string) (*euiccCha
}
func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string) (*euiccChannel, error) {
state, lookupErr := manager.lookup(id)
if lookupErr != nil {
return nil, lookupErr
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.openPCSCEuiccOnceAID(ctx, id, candidate, aidHex)
}
// MANAGE CHANNEL (open): 00 70 00 00 01 -> "<channel> 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.
@@ -302,6 +313,37 @@ func (manager *Manager) openEuiccOnceAID(ctx context.Context, id, aidHex string)
return channel, nil
}
func (manager *Manager) openPCSCEuiccOnceAID(ctx context.Context, id string, candidate modem.Candidate, aidHex string) (*euiccChannel, error) {
session, err := manager.cardReaders.OpenSession(ctx, pcsc.Selector{
USBPath: candidate.USBPath, ReaderName: candidate.ReaderName,
})
if err != nil {
return nil, err
}
payload, sw, err := session.Transmit(ctx, []byte{0x00, 0x70, 0x00, 0x00, 0x01})
if err != nil || sw != 0x9000 || len(payload) != 1 {
session.Close()
if err != nil {
return nil, fmt.Errorf("esim: PC/SC MANAGE CHANNEL: %w", err)
}
return nil, errNoLogicalChannel
}
channel := &euiccChannel{manager: manager, id: id, channel: int(payload[0]), pcscSession: session}
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...)
_, selectSW, err := channel.transmit(ctx, selectAID, 0x00)
if err != nil || selectSW != 0x9000 {
channel.close(context.Background())
return nil, errNoEUICC
}
return channel, nil
}
// discoverEuiccAIDs detects eSTK multi-SE and alternate-ISD-R cards without
// changing any profile state. The vendor product applet and candidate ISD-R
// applications are selected only as read-only capability probes. Per
@@ -352,7 +394,23 @@ func isTransientEuiccCME(err error) bool {
// 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)
_, _, _ = channel.exchange(ctx, closeAPDU)
if channel.pcscSession != nil {
if channel.resetOnClose {
_ = channel.pcscSession.CloseWithReset()
} else {
_ = channel.pcscSession.Close()
}
channel.pcscSession = nil
}
}
func (channel *euiccChannel) exchange(ctx context.Context, apdu []byte) ([]byte, int, error) {
if channel.pcscSession != nil {
payload, sw, err := channel.pcscSession.Transmit(ctx, apdu)
return payload, int(sw), err
}
return channel.manager.csim(ctx, channel.id, apdu)
}
// transmit sends one APDU on the logical channel (CLA high nibble from insClass,
@@ -360,7 +418,7 @@ func (channel *euiccChannel) close(ctx context.Context) {
// 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)
payload, sw, err := channel.exchange(ctx, apdu)
if err != nil {
return nil, 0, err
}
@@ -369,7 +427,7 @@ func (channel *euiccChannel) transmit(ctx context.Context, apdu []byte, insClass
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)
frag, nextSW, err := channel.exchange(ctx, getResponse)
if err != nil {
return nil, 0, err
}
@@ -610,6 +668,7 @@ func (manager *Manager) ESIMSwitchProfile(ctx context.Context, id string, iccid
// 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.resetOnClose = channel.pcscSession != nil
channel.close(closeContext)
cancelClose()
if err != nil {
@@ -780,9 +839,11 @@ func (manager *Manager) renameCachedProfile(id, iccid, nickname string) {
// 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()
if !manager.isPCSCDevice(id) {
resetContext, cancelReset := context.WithTimeout(context.Background(), manager.longTimeout)
_ = manager.rebootForProfileSwitch(resetContext, id)
cancelReset()
}
manager.refreshAfterProfileSwitch(id)
}
@@ -795,6 +856,20 @@ func (manager *Manager) recoverAfterProfileSwitch(id string) {
// 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) {
if manager.isPCSCDevice(id) {
time.Sleep(750 * time.Millisecond)
for attempt := 0; attempt < 10; attempt++ {
ctx, cancel := context.WithTimeout(context.Background(), manager.commandTimeout*4)
_, _ = manager.Discover(ctx)
_, err := manager.Refresh(ctx, id)
cancel()
if err == nil {
return
}
time.Sleep(time.Second)
}
return
}
const (
settle = 8 * time.Second
interval = 4 * time.Second
@@ -819,6 +894,14 @@ func (manager *Manager) refreshAfterProfileSwitch(id string) {
}
}
func (manager *Manager) isPCSCDevice(id string) bool {
state, err := manager.lookup(id)
if err != nil {
return false
}
return manager.candidateFor(state).HardwareKind == pcsc.HardwareKind
}
// 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) {
@@ -888,25 +971,37 @@ func (manager *Manager) verifySwitchedICCID(ctx context.Context, id, expected st
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
if manager.isPCSCDevice(id) {
snapshot, err := manager.Refresh(ctx, id)
if err == nil {
lastICCID = strings.TrimSpace(snapshot.ICCID)
if lastICCID == expected {
return nil
}
err = fmt.Errorf("reader still reports ICCID %s", lastICCID)
}
live := parseICCIDIdentifier(response, []string{"+CCID:", "+QCCID:"}, 18, 22)
if live == "" {
lastErr = errors.New("modem response contained no valid ICCID")
continue
lastErr = err
} else {
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
}
lastICCID = live
if live == expected {
return nil
}
lastErr = fmt.Errorf("modem still reports ICCID %s", live)
break
}
if attempt+1 < attempts {
select {
+9 -1
View File
@@ -52,7 +52,7 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
if err != nil {
return nil, err
}
if sw != 0x9000 {
if !es10StatusOK(sw) {
return nil, fmt.Errorf("%w: SW=%04X", errESIMSW, sw)
}
assembled = append(assembled, payload...)
@@ -62,6 +62,14 @@ func (channel *euiccChannel) storeDataChained(ctx context.Context, derRequest []
return assembled, nil
}
// 91xx is a successful UICC result with a proactive SIM Toolkit command
// pending. EnableProfile commonly returns it on direct PC/SC transports because
// the requested refresh is delivered to the terminal rather than consumed by
// modem firmware. Resetting the card after the operation applies that refresh.
func es10StatusOK(sw int) bool {
return sw == 0x9000 || sw>>8 == 0x91
}
// 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})
+13
View File
@@ -155,3 +155,16 @@ func TestEuiccFreeNVRAM(t *testing.T) {
t.Fatalf("expected ok=false when extCardResource absent")
}
}
func TestES10StatusAcceptsProactiveRefresh(t *testing.T) {
for _, status := range []int{0x9000, 0x9100, 0x910B, 0x91FF} {
if !es10StatusOK(status) {
t.Fatalf("status %04X should be successful", status)
}
}
for _, status := range []int{0x6A82, 0x6985, 0x9200} {
if es10StatusOK(status) {
t.Fatalf("status %04X should fail", status)
}
}
}
+77 -4
View File
@@ -10,6 +10,7 @@ import (
"time"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type Options struct {
@@ -19,6 +20,7 @@ type Options struct {
LongTimeout time.Duration
SMSTimeout time.Duration
ScanTimeout time.Duration
CardReaders *pcsc.Service
}
type Manager struct {
@@ -34,6 +36,7 @@ type Manager struct {
longTimeout time.Duration
smsTimeout time.Duration
scanTimeout time.Duration
cardReaders *pcsc.Service
started bool
devices map[string]*managedDevice
ussdSessions map[string]ussdSession
@@ -59,6 +62,7 @@ type managedDevice struct {
discovered bool
preFlightMode *int
resetClientOnLock bool
simPIN string
}
func NewManager(options Options) (*Manager, error) {
@@ -82,6 +86,9 @@ func NewManager(options Options) (*Manager, error) {
// AT+COPS=? can take well over a minute while the modem sweeps every band.
options.ScanTimeout = 150 * time.Second
}
if options.CardReaders == nil {
options.CardReaders = pcsc.New()
}
return &Manager{
discoverer: options.Discoverer,
opener: options.Opener,
@@ -89,6 +96,7 @@ func NewManager(options Options) (*Manager, error) {
longTimeout: options.LongTimeout,
smsTimeout: options.SMSTimeout,
scanTimeout: options.ScanTimeout,
cardReaders: options.CardReaders,
devices: make(map[string]*managedDevice),
ussdSessions: make(map[string]ussdSession),
esimRecoveries: make(map[string]chan struct{}),
@@ -146,9 +154,23 @@ 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
candidates, modemErr := manager.discoverer.Discover(ctx)
readers, readerErr := manager.cardReaders.Readers(ctx)
if readerErr == nil {
for _, reader := range readers {
candidates = append(candidates, modem.Candidate{
ID: pcsc.DeviceID(reader), HardwareKind: pcsc.HardwareKind,
ReaderName: reader.Name, USBPath: reader.USBPath,
VendorID: reader.VendorID, ProductID: reader.ProductID,
Manufacturer: reader.Manufacturer, Product: reader.Product,
})
}
}
if modemErr != nil && readerErr != nil && !errors.Is(readerErr, pcsc.ErrUnsupported) && !errors.Is(readerErr, pcsc.ErrUnavailable) {
return nil, errors.Join(modemErr, readerErr)
}
if modemErr != nil && len(candidates) == 0 {
return nil, modemErr
}
seen := make(map[string]struct{}, len(candidates))
@@ -360,6 +382,9 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return Snapshot{}, err
}
candidate := manager.candidateFor(state)
if candidate.HardwareKind == pcsc.HardwareKind {
return manager.refreshCardReader(ctx, id, state, candidate)
}
backend := manager.backendFor(state)
client, err := manager.clientLocked(ctx, state, candidate)
if err != nil {
@@ -375,11 +400,59 @@ func (manager *Manager) Refresh(ctx context.Context, id string) (Snapshot, error
return snapshot, err
}
func (manager *Manager) refreshCardReader(ctx context.Context, id string, state *managedDevice, candidate modem.Candidate) (Snapshot, error) {
result := Snapshot{
DeviceID: id, Port: candidate.ReaderName, Responsive: true,
Manufacturer: candidate.Manufacturer, Model: candidate.Product,
AccessTech: "Wi-Fi", RegistrationSource: "pcsc", OperatingMode: 4,
ModeKnown: true, FlightMode: true, RadioOff: true, UpdatedAt: time.Now().UTC(),
}
previousICCID := state.lastICCID
card, err := manager.cardReaders.Snapshot(ctx, pcsc.Selector{USBPath: candidate.USBPath, ReaderName: candidate.ReaderName}, state.simPIN)
if err != nil {
switch {
case errors.Is(err, pcsc.ErrNoCard):
result.SIMStatus = ""
err = nil
case errors.Is(err, pcsc.ErrPINRequired), errors.Is(err, pcsc.ErrPINTriesLow), errors.Is(err, pcsc.ErrPINRejected):
result.SIMStatus = "SIM PIN"
result.Warnings = []string{err.Error()}
err = nil
default:
manager.setResult(id, state, &result, err)
return result, err
}
} else {
result.SIMStatus = "READY"
result.SIMReady = true
result.ICCID = card.Identity.ICCID
result.IMSI = card.Identity.IMSI
result.SPN = card.Identity.SPN
result.SIMChanged = previousICCID != "" && !strings.EqualFold(previousICCID, result.ICCID)
state.lastICCID = result.ICCID
}
manager.setResult(id, state, &result, err)
return result, err
}
// SetSIMPin updates the in-memory PIN used for protected USIM files and AKA.
// It is deliberately never retained in runtime snapshots or logs.
func (manager *Manager) SetSIMPin(id, pin string) error {
manager.mu.Lock()
defer manager.mu.Unlock()
state := manager.devices[id]
if state == nil || !state.discovered {
return ErrNotFound
}
state.simPIN = strings.TrimSpace(pin)
return nil
}
// SetBackend selects which control plane supplies registration and data state.
// AT remains available in either mode for UICC, RF, SMS, voice and diagnostics.
func (manager *Manager) SetBackend(id, backend string) error {
backend = strings.ToLower(strings.TrimSpace(backend))
if backend != "at" && backend != "qmi" {
if backend != "at" && backend != "qmi" && backend != "pcsc" {
return fmt.Errorf("unsupported device backend %q", backend)
}
manager.mu.Lock()
+37
View File
@@ -6,8 +6,45 @@ import (
"testing"
"vocat/internal/modem"
"vocat/internal/pcsc"
)
type testPCSCBackend struct{ readers []pcsc.Reader }
func (backend testPCSCBackend) Readers(context.Context) ([]pcsc.Reader, error) {
return append([]pcsc.Reader(nil), backend.readers...), nil
}
func (testPCSCBackend) Open(context.Context, pcsc.Selector) (pcsc.Card, error) {
return nil, pcsc.ErrNoCard
}
func TestManagerDiscoversWiFiCallingOnlyReaderWithoutATPort(t *testing.T) {
manager, err := NewManager(Options{
Discoverer: staticDiscoverer{}, Opener: &staticOpener{},
CardReaders: pcsc.NewWithBackend(testPCSCBackend{readers: []pcsc.Reader{{
Name: "Alcor Link AK9563 00 00", USBPath: "1-3", Product: "AK9563",
}}}),
})
if err != nil {
t.Fatal(err)
}
if err := manager.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = manager.Stop(context.Background()) })
items := manager.List()
if len(items) != 1 || items[0].Candidate.HardwareKind != pcsc.HardwareKind || items[0].Candidate.HasATPort() {
t.Fatalf("discovered readers = %#v", items)
}
snapshot, err := manager.Refresh(context.Background(), items[0].ID)
if err != nil {
t.Fatal(err)
}
if !snapshot.Responsive || snapshot.SIMReady || snapshot.SIMStatus != "" || !snapshot.FlightMode {
t.Fatalf("reader snapshot = %#v", snapshot)
}
}
func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) {
client := &transcriptClient{steps: []clientStep{
{
+1
View File
@@ -11,6 +11,7 @@ var (
ErrNotStarted = errors.New("device manager is not started")
ErrNotFound = errors.New("device not found")
ErrNoATPort = errors.New("device has no usable AT port")
ErrUnsupportedCapability = errors.New("device does not support this capability")
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")
+2
View File
@@ -44,6 +44,8 @@ func (p Port) OpenPath() string {
}
type Candidate struct {
HardwareKind string `json:"hardwareKind,omitempty"`
ReaderName string `json:"readerName,omitempty"`
ID string `json:"id"`
VendorID string `json:"vendorId"`
ProductID string `json:"productId"`
+272
View File
@@ -0,0 +1,272 @@
//go:build linux && (amd64 || arm64)
package pcsc
import (
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"github.com/ElMostafaIdrassi/goscard"
)
type nativeBackend struct {
initializeOnce sync.Once
initializeErr error
}
func newNativeBackend() Backend { return &nativeBackend{} }
func (backend *nativeBackend) initialize() error {
backend.initializeOnce.Do(func() {
if err := goscard.Initialize(goscard.NewDefaultLogger(goscard.LogLevelNone)); err != nil {
backend.initializeErr = fmt.Errorf("%w: pcsc-lite client library could not be loaded", ErrUnavailable)
}
})
return backend.initializeErr
}
func (backend *nativeBackend) Readers(ctx context.Context) ([]Reader, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if err := backend.initialize(); err != nil {
return nil, err
}
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
if err != nil {
return nil, fmt.Errorf("%w: pcscd is not reachable", ErrUnavailable)
}
defer cardContext.Release()
names, _, err := cardContext.ListReaders(nil)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "no readers") {
return []Reader{}, nil
}
return nil, fmt.Errorf("pcsc: list readers: %w", err)
}
presentNames, atrs, _, _ := cardContext.ListReadersWithCardPresent(nil)
present := make(map[string]string, len(presentNames))
for index, name := range presentNames {
atr := ""
if index < len(atrs) {
atr = atrs[index]
}
present[name] = atr
}
readers := make([]Reader, 0, len(names))
for _, name := range names {
reader := Reader{Name: name}
reader.ATR, reader.CardPresent = present[name]
if path, ok := backend.readerUSBPath(cardContext, name); ok {
reader.USBPath = path
reader.VendorID = readSysfsText(path, "idVendor")
reader.ProductID = readSysfsText(path, "idProduct")
reader.Manufacturer = readSysfsText(path, "manufacturer")
reader.Product = readSysfsText(path, "product")
} else {
reader.USBPath = "pcsc:" + name
}
if reader.Product == "" {
reader.Product = strings.TrimSpace(strings.TrimSuffix(name, " 00 00"))
}
readers = append(readers, reader)
}
return readers, nil
}
func (backend *nativeBackend) readerUSBPath(cardContext goscard.Context, name string) (string, bool) {
card, _, err := cardContext.Connect(name, goscard.SCardShareDirect, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
if err != nil {
return "", false
}
defer card.Disconnect(goscard.SCardLeaveCard)
attribute, _, err := card.GetAttrib(goscard.SCardAttrChannelID)
if err != nil || len(attribute) < 4 {
return "", false
}
channel := binary.LittleEndian.Uint32(attribute[:4])
if channel>>16 != 0x0020 {
return "", false
}
bus, device := int((channel>>8)&0xFF), int(channel&0xFF)
entries, err := os.ReadDir("/sys/bus/usb/devices")
if err != nil {
return "", false
}
for _, entry := range entries {
if !entry.IsDir() && entry.Type()&os.ModeSymlink == 0 {
continue
}
path := filepath.Join("/sys/bus/usb/devices", entry.Name())
entryBus, busErr := readSysfsInt(path, "busnum")
entryDevice, deviceErr := readSysfsInt(path, "devnum")
if busErr == nil && deviceErr == nil && entryBus == bus && entryDevice == device {
return entry.Name(), true
}
}
return "", false
}
func (backend *nativeBackend) Open(ctx context.Context, selector Selector) (Card, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
readers, err := backend.Readers(ctx)
if err != nil {
return nil, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return nil, ErrReaderNotFound
}
if !reader.CardPresent {
return nil, ErrNoCard
}
cardContext, _, err := goscard.NewContext(goscard.SCardScopeSystem, nil, nil)
if err != nil {
return nil, fmt.Errorf("%w: create context", ErrUnavailable)
}
card, _, err := cardContext.Connect(reader.Name, goscard.SCardShareShared, goscard.SCardProtocolT0|goscard.SCardProtocolT1)
if err != nil {
cardContext.Release()
return nil, fmt.Errorf("pcsc: connect reader: %w", err)
}
if _, err := card.BeginTransaction(); err != nil {
card.Disconnect(goscard.SCardLeaveCard)
cardContext.Release()
return nil, fmt.Errorf("pcsc: begin card transaction: %w", err)
}
return &nativeCard{context: &cardContext, card: &card}, nil
}
type nativeCard struct {
context *goscard.Context
card *goscard.Card
closed bool
}
func (card *nativeCard) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.card == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
return card.transmit(ctx, append([]byte(nil), command...), 0)
}
// TransmitRaw performs exactly one APDU exchange. Stateful eUICC callers need
// to observe 61xx themselves because GET RESPONSE must target their logical
// channel rather than the basic channel.
func (card *nativeCard) TransmitRaw(ctx context.Context, command []byte) ([]byte, uint16, error) {
if card == nil || card.card == nil || card.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
pci := goscard.SCardIoRequestT0
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
pci = goscard.SCardIoRequestT1
}
response, _, err := card.card.Transmit(&pci, append([]byte(nil), command...), nil)
if err != nil {
return nil, 0, err
}
if len(response) < 2 {
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
last := len(response) - 2
return append([]byte(nil), response[:last]...), uint16(response[last])<<8 | uint16(response[last+1]), nil
}
func (card *nativeCard) transmit(ctx context.Context, command []byte, depth int) ([]byte, uint16, error) {
if depth > 8 {
return nil, 0, errors.New("pcsc: too many APDU continuations")
}
pci := goscard.SCardIoRequestT0
if card.card.ActiveProtocol() == goscard.SCardProtocolT1 {
pci = goscard.SCardIoRequestT1
}
response, _, err := card.card.Transmit(&pci, command, nil)
if err != nil {
return nil, 0, err
}
if len(response) < 2 {
return nil, 0, errors.New("pcsc: APDU response omitted its status word")
}
data := append([]byte(nil), response[:len(response)-2]...)
sw1, sw2 := response[len(response)-2], response[len(response)-1]
if sw1 == 0x6C && len(command) >= 5 {
retry := append([]byte(nil), command...)
retry[len(retry)-1] = sw2
return card.transmit(ctx, retry, depth+1)
}
if sw1 == 0x61 || sw1 == 0x9F {
more, sw, err := card.transmit(ctx, []byte{0x00, 0xC0, 0x00, 0x00, sw2}, depth+1)
if err != nil {
return nil, 0, err
}
return append(data, more...), sw, nil
}
if err := ctx.Err(); err != nil {
return nil, 0, err
}
return data, uint16(sw1)<<8 | uint16(sw2), nil
}
func (card *nativeCard) Close() error {
return card.close(goscard.SCardLeaveCard)
}
func (card *nativeCard) CloseWithReset() error {
return card.close(goscard.SCardResetCard)
}
func (card *nativeCard) close(disposition goscard.SCardDisposition) error {
if card == nil || card.closed {
return nil
}
card.closed = true
var result []error
if card.card != nil {
if _, err := card.card.EndTransaction(disposition); err != nil {
result = append(result, err)
}
if _, err := card.card.Disconnect(disposition); err != nil {
result = append(result, err)
}
}
if card.context != nil {
if _, err := card.context.Release(); err != nil {
result = append(result, err)
}
}
return errors.Join(result...)
}
func readSysfsText(usbPath, name string) string {
value, err := os.ReadFile(filepath.Join("/sys/bus/usb/devices", usbPath, name))
if err != nil {
return ""
}
return strings.TrimSpace(string(value))
}
func readSysfsInt(path, name string) (int, error) {
value, err := os.ReadFile(filepath.Join(path, name))
if err != nil {
return 0, err
}
return strconv.Atoi(strings.TrimSpace(string(value)))
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !linux || (!amd64 && !arm64)
package pcsc
import "context"
type unsupportedBackend struct{}
func newNativeBackend() Backend { return unsupportedBackend{} }
func (unsupportedBackend) Readers(context.Context) ([]Reader, error) {
return nil, ErrUnsupported
}
func (unsupportedBackend) Open(context.Context, Selector) (Card, error) {
return nil, ErrUnsupported
}
+610
View File
@@ -0,0 +1,610 @@
package pcsc
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
)
const usimAIDPrefix = "A0000000871002"
type Service struct {
mu sync.Mutex
backend Backend
}
// Session is an exclusive connection to one smart card. It is used by eUICC
// operations which must keep the same PC/SC transaction and logical channel
// alive across a sequence of APDUs.
type Session struct {
service *Service
card Card
closed bool
}
func New() *Service {
return &Service{backend: newNativeBackend()}
}
func NewWithBackend(backend Backend) *Service {
return &Service{backend: backend}
}
func DeviceID(reader Reader) string {
identity := strings.TrimSpace(reader.USBPath)
if identity == "" {
identity = strings.TrimSpace(reader.Name)
}
sum := sha256.Sum256([]byte(identity))
return "reader-" + hex.EncodeToString(sum[:8])
}
func (service *Service) Readers(ctx context.Context) ([]Reader, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
return service.backend.Readers(ctx)
}
// OpenSession opens one card and holds the service lock until Close. Callers
// must close the returned session; this prevents AKA/identity reads from
// interleaving with a stateful ES10 transaction.
func (service *Service) OpenSession(ctx context.Context, selector Selector) (*Session, error) {
if service == nil || service.backend == nil {
return nil, ErrUnavailable
}
if err := selector.validate(); err != nil {
return nil, err
}
service.mu.Lock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
service.mu.Unlock()
return nil, err
}
return &Session{service: service, card: card}, nil
}
// Transmit sends one raw APDU. Unlike the ordinary SIM helpers, it leaves
// 61xx continuation handling to the eUICC logical-channel implementation so
// GET RESPONSE uses the correct channel CLA.
func (session *Session) Transmit(ctx context.Context, command []byte) ([]byte, uint16, error) {
if session == nil || session.card == nil || session.closed {
return nil, 0, errors.New("pcsc: card session is closed")
}
if raw, ok := session.card.(interface {
TransmitRaw(context.Context, []byte) ([]byte, uint16, error)
}); ok {
return raw.TransmitRaw(ctx, command)
}
return session.card.Transmit(ctx, command)
}
func (session *Session) Close() error {
return session.close(false)
}
// CloseWithReset resets the card while releasing the PC/SC connection. eUICC
// EnableProfile requires this refresh boundary before the newly enabled USIM
// application and ICCID become visible to subsequent callers.
func (session *Session) CloseWithReset() error {
return session.close(true)
}
func (session *Session) close(reset bool) error {
if session == nil || session.closed {
return nil
}
session.closed = true
var err error
if resetter, ok := session.card.(interface{ CloseWithReset() error }); reset && ok {
err = resetter.CloseWithReset()
} else {
err = session.card.Close()
}
session.service.mu.Unlock()
return err
}
func (service *Service) Snapshot(ctx context.Context, selector Selector, pin string) (Snapshot, error) {
readers, err := service.Readers(ctx)
if err != nil {
return Snapshot{}, err
}
reader, ok := matchReader(readers, selector)
if !ok {
return Snapshot{}, ErrReaderNotFound
}
result := Snapshot{Reader: reader}
if !reader.CardPresent {
return result, ErrNoCard
}
identity, err := service.ReadIdentity(ctx, selector, pin)
result.Identity = identity
return result, err
}
func (service *Service) ReadIdentity(ctx context.Context, selector Selector, pin string) (Identity, error) {
if service == nil || service.backend == nil {
return Identity{}, ErrUnavailable
}
if err := selector.validate(); err != nil {
return Identity{}, err
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return Identity{}, err
}
defer card.Close()
return readIdentity(ctx, card, pin)
}
func (service *Service) CheckReady(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
) (string, error) {
if service == nil || service.backend == nil {
return "", ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return "", err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return "", err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return "", ErrCardChanged
}
aid, err := selectUSIM(ctx, card)
if err != nil {
return "", err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return "", err
}
return strings.ToUpper(hex.EncodeToString(aid)), nil
}
func (service *Service) Authenticate(
ctx context.Context,
selector Selector,
expectedICCID string,
pin string,
challenge AKAChallenge,
) (AKAResult, error) {
if service == nil || service.backend == nil {
return AKAResult{}, ErrUnavailable
}
service.mu.Lock()
defer service.mu.Unlock()
card, err := service.backend.Open(ctx, selector)
if err != nil {
return AKAResult{}, err
}
defer card.Close()
iccid, err := readICCID(ctx, card)
if err != nil {
return AKAResult{}, err
}
if expected := strings.TrimSpace(expectedICCID); expected != "" && !strings.EqualFold(expected, iccid) {
return AKAResult{}, ErrCardChanged
}
if _, err := selectUSIM(ctx, card); err != nil {
return AKAResult{}, err
}
if err := verifyPIN(ctx, card, pin); err != nil {
return AKAResult{}, err
}
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)
data, sw, err := card.Transmit(ctx, apdu)
if err != nil {
return AKAResult{}, errors.New("pcsc: USIM authentication transport failed")
}
if sw == 0x9862 {
return AKAResult{}, ErrAKARejected
}
if sw != 0x9000 {
return AKAResult{}, fmt.Errorf("pcsc: USIM authentication failed with status %04X", sw)
}
return parseAKAResponse(data)
}
func matchReader(readers []Reader, selector Selector) (Reader, bool) {
path := strings.TrimSpace(selector.USBPath)
name := strings.TrimSpace(selector.ReaderName)
for _, reader := range readers {
if path != "" && reader.USBPath == path {
return reader, true
}
}
for _, reader := range readers {
if name != "" && reader.Name == name {
return reader, true
}
}
return Reader{}, false
}
func readIdentity(ctx context.Context, card Card, pin string) (Identity, error) {
identity := Identity{PINTries: -1}
iccid, err := readICCID(ctx, card)
if err != nil {
return identity, err
}
identity.ICCID = iccid
aid, err := selectUSIM(ctx, card)
if err != nil {
return identity, err
}
identity.USIMAID = append([]byte(nil), aid...)
if err := verifyPIN(ctx, card, pin); err != nil {
identity.PINRequired = errors.Is(err, ErrPINRequired) || errors.Is(err, ErrPINTriesLow)
var pinErr *PINError
if errors.As(err, &pinErr) {
identity.PINTries = pinErr.Tries
}
return identity, err
}
if err := selectFile(ctx, card, []byte{0x6F, 0x07}); err != nil {
return identity, fmt.Errorf("pcsc: select EF_IMSI: %w", err)
}
imsiData, err := readBinary(ctx, card, 9)
if err != nil {
return identity, fmt.Errorf("pcsc: read EF_IMSI: %w", err)
}
identity.IMSI, err = decodeIMSI(imsiData)
if err != nil {
return identity, err
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
if selectErr = selectFile(ctx, card, []byte{0x6F, 0xAD}); selectErr == nil {
if data, readErr := readBinary(ctx, card, 4); readErr == nil && len(data) >= 4 {
length := int(data[3] & 0x0f)
if length == 2 || length == 3 {
identity.MNCLength = length
}
}
}
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SPN = readSPN(ctx, card)
}
if _, selectErr := selectApplication(ctx, card, aid); selectErr == nil {
identity.SMSC = readSMSC(ctx, card)
}
return identity, nil
}
func readICCID(ctx context.Context, card Card) (string, error) {
if err := selectMF(ctx, card); err != nil {
return "", err
}
if err := selectFile(ctx, card, []byte{0x2F, 0xE2}); err != nil {
return "", fmt.Errorf("pcsc: select EF_ICCID: %w", err)
}
data, err := readBinary(ctx, card, 10)
if err != nil {
return "", fmt.Errorf("pcsc: read EF_ICCID: %w", err)
}
value := decodeSwappedBCD(data, false)
if len(value) < 18 || len(value) > 22 {
return "", errors.New("pcsc: card returned an invalid ICCID")
}
return value, nil
}
func selectUSIM(ctx context.Context, card Card) ([]byte, error) {
if err := selectMF(ctx, card); err != nil {
return nil, err
}
if err := selectFile(ctx, card, []byte{0x2F, 0x00}); err != nil {
return nil, fmt.Errorf("pcsc: select EF_DIR: %w", err)
}
var usimAID []byte
for record := 1; record <= 32; record++ {
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, byte(record), 0x04, 0x00})
if err != nil {
return nil, err
}
if sw == 0x6A83 || sw == 0x9402 {
break
}
if sw != 0x9000 {
continue
}
aid := findTLV(data, 0x4F)
if len(aid) == 0 {
continue
}
if strings.HasPrefix(strings.ToUpper(hex.EncodeToString(aid)), usimAIDPrefix) {
usimAID = append([]byte(nil), aid...)
break
}
}
if len(usimAID) == 0 {
return nil, ErrUSIMUnavailable
}
if _, err := selectApplication(ctx, card, usimAID); err != nil {
return nil, err
}
return usimAID, nil
}
func selectMF(ctx context.Context, card Card) error {
_, sw, err := card.Transmit(ctx, []byte{0x00, 0xA4, 0x00, 0x04, 0x02, 0x3F, 0x00, 0x00})
return requireStatus("select MF", sw, err)
}
func selectFile(ctx context.Context, card Card, fileID []byte) error {
if len(fileID) != 2 {
return errors.New("pcsc: invalid file identifier")
}
apdu := []byte{0x00, 0xA4, 0x00, 0x04, 0x02, fileID[0], fileID[1], 0x00}
_, sw, err := card.Transmit(ctx, apdu)
return requireStatus("select file", sw, err)
}
func selectApplication(ctx context.Context, card Card, aid []byte) ([]byte, error) {
if len(aid) == 0 || len(aid) > 32 {
return nil, errors.New("pcsc: invalid USIM AID")
}
apdu := []byte{0x00, 0xA4, 0x04, 0x04, byte(len(aid))}
apdu = append(apdu, aid...)
apdu = append(apdu, 0x00)
data, sw, err := card.Transmit(ctx, apdu)
if err := requireStatus("select USIM application", sw, err); err != nil {
return nil, err
}
return data, nil
}
func readBinary(ctx context.Context, card Card, length int) ([]byte, error) {
if length <= 0 || length > 256 {
return nil, errors.New("pcsc: invalid binary read length")
}
le := byte(length)
if length == 256 {
le = 0
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB0, 0x00, 0x00, le})
if err := requireStatus("read binary", sw, err); err != nil {
return nil, err
}
return data, nil
}
func verifyPIN(ctx context.Context, card Card, pin string) error {
pin = strings.TrimSpace(pin)
if pin == "" {
return nil
}
if len(pin) < 4 || len(pin) > 8 || !decimalDigits(pin) {
return errors.New("pcsc: SIM PIN must contain 4 to 8 digits")
}
_, sw, err := card.Transmit(ctx, []byte{0x00, 0x20, 0x00, 0x01, 0x00})
if err != nil {
return errors.New("pcsc: SIM PIN status check failed")
}
if sw == 0x9000 {
return nil
}
tries := -1
if sw&0xFFF0 == 0x63C0 {
tries = int(sw & 0x000F)
if tries <= 2 {
return &PINError{Kind: ErrPINTriesLow, Tries: tries}
}
}
body := bytes.Repeat([]byte{0xFF}, 8)
copy(body, []byte(pin))
apdu := append([]byte{0x00, 0x20, 0x00, 0x01, 0x08}, body...)
_, sw, err = card.Transmit(ctx, apdu)
if err != nil {
return errors.New("pcsc: SIM PIN verification transport failed")
}
if sw == 0x9000 {
return nil
}
if sw&0xFFF0 == 0x63C0 {
return &PINError{Kind: ErrPINRejected, Tries: int(sw & 0x000F)}
}
return ErrPINRejected
}
func requireStatus(operation string, sw uint16, err error) error {
if err != nil {
return fmt.Errorf("pcsc: %s transport failed", operation)
}
if sw == 0x9000 {
return nil
}
if sw == 0x6982 || sw == 0x9804 {
return &PINError{Kind: ErrPINRequired, Tries: -1}
}
return fmt.Errorf("pcsc: %s failed with status %04X", operation, sw)
}
func decodeSwappedBCD(value []byte, dropFirstNibble bool) string {
var result strings.Builder
for _, octet := range value {
for _, nibble := range []byte{octet & 0x0F, octet >> 4} {
if dropFirstNibble {
dropFirstNibble = false
continue
}
if nibble == 0x0F {
return result.String()
}
if nibble > 9 {
return ""
}
result.WriteByte('0' + nibble)
}
}
return result.String()
}
func decodeIMSI(data []byte) (string, error) {
if len(data) < 2 {
return "", errors.New("pcsc: EF_IMSI is too short")
}
length := int(data[0])
if length <= 0 || length > len(data)-1 {
return "", errors.New("pcsc: EF_IMSI has an invalid length")
}
value := decodeSwappedBCD(data[1:1+length], true)
if len(value) < 10 || len(value) > 18 || !decimalDigits(value) {
return "", errors.New("pcsc: card returned an invalid IMSI")
}
return value, nil
}
func decimalDigits(value string) bool {
if value == "" {
return false
}
for _, character := range value {
if character < '0' || character > '9' {
return false
}
}
return true
}
func findTLV(data []byte, wanted byte) []byte {
for len(data) >= 2 {
tag := data[0]
data = data[1:]
length, consumed, ok := decodeTLVLength(data)
if !ok || consumed+length > len(data) {
return nil
}
value := data[consumed : consumed+length]
if tag == wanted {
return append([]byte(nil), value...)
}
if tag&0x20 != 0 {
if nested := findTLV(value, wanted); len(nested) > 0 {
return nested
}
}
data = data[consumed+length:]
}
return nil
}
func decodeTLVLength(data []byte) (length, consumed int, ok bool) {
if len(data) == 0 {
return 0, 0, false
}
if data[0]&0x80 == 0 {
return int(data[0]), 1, true
}
count := int(data[0] & 0x7F)
if count < 1 || count > 2 || len(data) < 1+count {
return 0, 0, false
}
length = 0
for _, octet := range data[1 : 1+count] {
length = length<<8 | int(octet)
}
return length, 1 + count, true
}
func parseAKAResponse(data []byte) (AKAResult, error) {
if len(data) < 2 {
return AKAResult{}, errors.New("pcsc: USIM returned a short AKA response")
}
switch data[0] {
case 0xDB:
res, rest, ok := takeLV(data[1:])
if !ok || len(res) < 4 || len(res) > 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA RES")
}
ck, rest, ok := takeLV(rest)
if !ok || len(ck) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA CK")
}
ik, rest, ok := takeLV(rest)
if !ok || len(ik) != 16 {
return AKAResult{}, errors.New("pcsc: USIM returned an invalid AKA IK")
}
if len(rest) > 0 {
kc, tail, valid := takeLV(rest)
if !valid || len(kc) != 8 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid trailing AKA material")
}
}
return AKAResult{RES: append([]byte(nil), res...), CK: append([]byte(nil), ck...), IK: append([]byte(nil), ik...)}, nil
case 0xDC:
auts, tail, ok := takeLV(data[1:])
if !ok || len(auts) != 14 || len(tail) != 0 {
return AKAResult{}, errors.New("pcsc: USIM returned invalid AKA synchronization evidence")
}
return AKAResult{AUTS: append([]byte(nil), auts...), SynchronizationFailure: true}, nil
default:
return AKAResult{}, errors.New("pcsc: USIM returned an unsupported AKA response")
}
}
func takeLV(data []byte) (value, rest []byte, ok bool) {
if len(data) == 0 || int(data[0]) > len(data)-1 {
return nil, data, false
}
length := int(data[0])
return data[1 : 1+length], data[1+length:], true
}
func readSPN(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x46}); err != nil {
return ""
}
data, err := readBinary(ctx, card, 17)
if err != nil || len(data) < 2 {
return ""
}
return strings.TrimSpace(strings.TrimRight(string(data[1:]), "\x00\xFF"))
}
func readSMSC(ctx context.Context, card Card) string {
if err := selectFile(ctx, card, []byte{0x6F, 0x42}); err != nil {
return ""
}
data, sw, err := card.Transmit(ctx, []byte{0x00, 0xB2, 0x01, 0x04, 0x00})
if err != nil || sw != 0x9000 || len(data) < 15 {
return ""
}
sca := data[len(data)-15 : len(data)-3]
if len(sca) < 2 || sca[0] < 2 || int(sca[0]) > len(sca)-1 {
return ""
}
digits := decodeSwappedBCD(sca[2:1+int(sca[0])], false)
if !decimalDigits(digits) {
return ""
}
if sca[1]&0x70 == 0x10 {
return "+" + digits
}
return digits
}
+84
View File
@@ -0,0 +1,84 @@
package pcsc
import (
"bytes"
"context"
"errors"
"testing"
)
type scriptedReply struct {
data []byte
sw uint16
}
type scriptedCard struct {
replies []scriptedReply
calls [][]byte
}
func (card *scriptedCard) Transmit(_ context.Context, command []byte) ([]byte, uint16, error) {
card.calls = append(card.calls, append([]byte(nil), command...))
if len(card.replies) == 0 {
return nil, 0, errors.New("unexpected APDU")
}
reply := card.replies[0]
card.replies = card.replies[1:]
return append([]byte(nil), reply.data...), reply.sw, nil
}
func (*scriptedCard) Close() error { return nil }
func TestDecodeIdentifiers(t *testing.T) {
if got := decodeSwappedBCD([]byte{0x98, 0x10, 0x32, 0x54, 0xF6}, false); got != "890123456" {
t.Fatalf("ICCID BCD = %q", got)
}
imsi, err := decodeIMSI([]byte{0x08, 0x19, 0x32, 0x54, 0x76, 0x98, 0x10, 0x32, 0x54})
if err != nil {
t.Fatal(err)
}
if imsi != "123456789012345" {
t.Fatalf("IMSI = %q", imsi)
}
}
func TestVerifyPINRefusesLowAttemptCount(t *testing.T) {
card := &scriptedCard{replies: []scriptedReply{{sw: 0x63C2}}}
err := verifyPIN(context.Background(), card, "1234")
if !errors.Is(err, ErrPINTriesLow) {
t.Fatalf("error = %v", err)
}
if len(card.calls) != 1 {
t.Fatalf("APDU calls = %d, PIN must not be submitted", len(card.calls))
}
}
func TestParseAKAResponse(t *testing.T) {
data := []byte{0xDB, 0x08, 1, 2, 3, 4, 5, 6, 7, 8, 0x10}
data = append(data, bytes.Repeat([]byte{0xAA}, 16)...)
data = append(data, 0x10)
data = append(data, bytes.Repeat([]byte{0xBB}, 16)...)
result, err := parseAKAResponse(data)
if err != nil {
t.Fatal(err)
}
if len(result.RES) != 8 || len(result.CK) != 16 || len(result.IK) != 16 || result.SynchronizationFailure {
t.Fatalf("unexpected AKA result: %#v", result)
}
syncResult, err := parseAKAResponse(append([]byte{0xDC, 0x0E}, bytes.Repeat([]byte{0xCC}, 14)...))
if err != nil {
t.Fatal(err)
}
if !syncResult.SynchronizationFailure || len(syncResult.AUTS) != 14 {
t.Fatalf("unexpected sync result: %#v", syncResult)
}
}
func TestDeviceIDUsesStableUSBPath(t *testing.T) {
a := DeviceID(Reader{Name: "reader 00 00", USBPath: "1-3"})
b := DeviceID(Reader{Name: "renamed reader", USBPath: "1-3"})
if a != b || a == "" {
t.Fatalf("device IDs = %q, %q", a, b)
}
}
+107
View File
@@ -0,0 +1,107 @@
package pcsc
import (
"context"
"errors"
"fmt"
"strings"
)
const HardwareKind = "pcsc"
var (
ErrUnsupported = errors.New("pcsc: platform is not supported")
ErrUnavailable = errors.New("pcsc: service is unavailable")
ErrReaderNotFound = errors.New("pcsc: reader not found")
ErrNoCard = errors.New("pcsc: no card is inserted")
ErrPINRequired = errors.New("pcsc: SIM PIN is required")
ErrPINTriesLow = errors.New("pcsc: refusing PIN verification because too few attempts remain")
ErrPINRejected = errors.New("pcsc: SIM PIN was rejected")
ErrUSIMUnavailable = errors.New("pcsc: no usable USIM application was found")
ErrCardChanged = errors.New("pcsc: card identity changed during authentication")
ErrAKARejected = errors.New("pcsc: USIM rejected the network authentication token")
)
type Reader struct {
Name string
USBPath string
VendorID string
ProductID string
Manufacturer string
Product string
CardPresent bool
ATR string
}
type Selector struct {
USBPath string
ReaderName string
}
func (selector Selector) validate() error {
if strings.TrimSpace(selector.USBPath) == "" && strings.TrimSpace(selector.ReaderName) == "" {
return errors.New("pcsc: reader selector is empty")
}
return nil
}
type Identity struct {
ICCID string
IMSI string
MNCLength int
USIMAID []byte
SMSC string
SPN string
PINRequired bool
PINTries int
}
type Snapshot struct {
Reader Reader
Identity Identity
}
type AKAChallenge struct {
RAND [16]byte
AUTN [16]byte
}
type AKAResult struct {
RES []byte
CK []byte
IK []byte
AUTS []byte
SynchronizationFailure bool
}
type PINError struct {
Kind error
Tries int
}
func (err *PINError) Error() string {
if err == nil {
return "pcsc: SIM PIN error"
}
if err.Tries >= 0 {
return fmt.Sprintf("%v (%d attempts remain)", err.Kind, err.Tries)
}
return err.Kind.Error()
}
func (err *PINError) Unwrap() error {
if err == nil {
return nil
}
return err.Kind
}
type Card interface {
Transmit(context.Context, []byte) ([]byte, uint16, error)
Close() error
}
type Backend interface {
Readers(context.Context) ([]Reader, error)
Open(context.Context, Selector) (Card, error)
}
@@ -56,7 +56,7 @@ func (s *Server) notifyAutomaticTask(ctx context.Context, task store.AutomaticTa
}, "\n"),
Time: run.FinishedAt, Task: task, Run: run,
}
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook"} {
for _, channel := range []string{"telegram", "bark", "email", "pushplus", "webhook", "wecom"} {
setting, err := s.store.NotificationSetting(ctx, channel)
if errors.Is(err, store.ErrNotFound) || (err == nil && !setting.Enabled) {
continue
@@ -88,6 +88,8 @@ func sendAutomaticTaskNotification(ctx context.Context, channel string, config m
return sendPushplusTextNotification(ctx, config, message.Title, message.Text)
case "webhook":
return sendAutomaticTaskWebhook(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomAutomaticTaskValues(message))
default:
return fmt.Errorf("unsupported notification channel %q", channel)
}
+49 -1
View File
@@ -231,6 +231,9 @@ func (s *Server) ensureAutomaticTaskProfile(ctx context.Context, task store.Auto
if err != nil {
return store.Device{}, device.Device{}, "", fmt.Errorf("read device: %w", err)
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
return store.Device{}, device.Device{}, "", err
}
entry, physicalID, present := s.physicalForConfig(config)
if !present || entry.Snapshot == nil {
return store.Device{}, device.Device{}, "", errors.New("configured device is offline")
@@ -511,6 +514,25 @@ func (s *Server) restoreAutomaticTaskEnvironment(physicalID string, snapshot aut
if err := s.store.UpsertDevice(cleanupContext, config); err != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("persist device policy: %w", err))
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
if s.vowifi == nil {
return errors.Join(append(restoreErrors, errors.New("VoWiFi runtime is unavailable"))...)
}
state, stateErr := s.vowifi.State(config.ID)
if policy.VoWiFiEnabled {
if stateErr == nil && state.Enabled {
_, stateErr = s.vowifi.RequestReconnect(config.ID)
} else {
_, stateErr = s.vowifi.RequestEnabled(config.ID, true)
}
} else if stateErr == nil && (state.Enabled || state.Active) {
_, stateErr = s.vowifi.RequestEnabled(config.ID, false)
}
if stateErr != nil {
restoreErrors = append(restoreErrors, fmt.Errorf("restore reader VoWiFi: %w", stateErr))
}
return errors.Join(restoreErrors...)
}
if policy.VoWiFiEnabled {
if _, err := s.devices.SetNetwork(cleanupContext, physicalID, s.cardNetworkRequest(cleanupContext, physicalID, config, policy, false)); err != nil {
@@ -710,6 +732,15 @@ func (s *Server) handleAutomaticTaskRunNow(w http.ResponseWriter, r *http.Reques
s.writeStoreError(w, err)
return
}
config, err := s.store.Device(r.Context(), task.DeviceID)
if err != nil {
s.writeStoreError(w, err)
return
}
if err := validateAutomaticTaskDeviceCapabilities(config, task.TaskType, task.Environment); err != nil {
writeError(w, http.StatusConflict, "wifi_calling_only_device", err.Error())
return
}
run, err := s.store.QueueAutomaticTaskNow(r.Context(), task)
if err != nil {
s.writeStoreError(w, err)
@@ -745,7 +776,8 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.Name == "" || request.DeviceID == "" || request.ProfileICCID == "" {
return store.AutomaticTask{}, errors.New("name, device, and eSIM profile are required")
}
if _, err := s.store.Device(r.Context(), request.DeviceID); err != nil {
selectedDevice, err := s.store.Device(r.Context(), request.DeviceID)
if err != nil {
return store.AutomaticTask{}, errors.New("selected device does not exist")
}
if request.Environment != "vowifi" && request.Environment != "cellular" {
@@ -757,6 +789,9 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
if request.TaskType == "public_ip" && request.Environment != "cellular" {
return store.AutomaticTask{}, errors.New("public IP tasks must use cellular direct mode")
}
if err := validateAutomaticTaskDeviceCapabilities(selectedDevice, request.TaskType, request.Environment); err != nil {
return store.AutomaticTask{}, err
}
if request.IntervalDays < 1 || request.IntervalDays > 365 || request.RetryCount < 0 || request.RetryCount > 10 {
return store.AutomaticTask{}, errors.New("interval_days must be 1-365 and retry_count must be 0-10")
}
@@ -796,6 +831,19 @@ func (s *Server) decodeAutomaticTask(r *http.Request, id int64) (store.Automatic
return task, nil
}
func validateAutomaticTaskDeviceCapabilities(config store.Device, taskType, environment string) error {
if config.DeviceType != store.DeviceTypeUSBSIMReader {
return nil
}
if environment != "vowifi" {
return errors.New("USB SIM reader tasks must use the VoWiFi environment")
}
if taskType != "sms" && taskType != "call" {
return errors.New("USB SIM readers support only VoWiFi SMS and call tasks")
}
return nil
}
func nextAutomaticRun(date, clock string, intervalDays int, now time.Time) (time.Time, error) {
location := now.Location()
start, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(date)+" "+strings.TrimSpace(clock), location)
+21
View File
@@ -3,6 +3,8 @@ package server
import (
"testing"
"time"
"vocat/internal/store"
)
func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
@@ -18,6 +20,25 @@ func TestNextAutomaticRunUsesIntervalAndLocalClock(t *testing.T) {
}
}
func TestUSBSIMReaderAutomaticTasksRequireVoWiFi(t *testing.T) {
reader := store.Device{DeviceType: store.DeviceTypeUSBSIMReader}
for _, test := range []struct {
taskType string
environment string
wantError bool
}{
{taskType: "sms", environment: "vowifi"},
{taskType: "call", environment: "vowifi"},
{taskType: "sms", environment: "cellular", wantError: true},
{taskType: "public_ip", environment: "cellular", wantError: true},
} {
err := validateAutomaticTaskDeviceCapabilities(reader, test.taskType, test.environment)
if (err != nil) != test.wantError {
t.Errorf("type=%s environment=%s error=%v", test.taskType, test.environment, err)
}
}
}
func TestAutomaticSMSRetrySafetyPreventsDuplicateSubmission(t *testing.T) {
unsafe := []byte(`{"data":{"parts_attempted":1,"parts_accepted":1,"retry_safe":false}}`)
if automaticSMSRetrySafe(unsafe) {
+67 -3
View File
@@ -66,6 +66,7 @@ type deviceConfigPayload struct {
USBPath string `json:"usb_path"`
AudioDevice string `json:"audio_device"`
ModemIMEI string `json:"modem_imei"`
SIMPIN string `json:"sim_pin"`
APN string `json:"apn"`
ProxyPort int `json:"proxy_port"`
BaudRate int `json:"baud_rate"`
@@ -97,6 +98,7 @@ func (payload deviceConfigPayload) toStoreDevice() store.Device {
USBPath: strings.TrimSpace(payload.USBPath),
AudioDevice: strings.TrimSpace(payload.AudioDevice),
ModemIMEI: strings.TrimSpace(payload.ModemIMEI),
SIMPIN: strings.TrimSpace(payload.SIMPIN),
APN: strings.TrimSpace(payload.APN),
ProxyPort: payload.ProxyPort,
BaudRate: payload.BaudRate,
@@ -246,6 +248,12 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) bool {
config.NetworkEnabled = false
}
fillConfigFromPhysical(&config, *selected)
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
if err := pinSetter.SetSIMPin(selected.ID, config.SIMPIN); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(selected.ID, config.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
@@ -363,6 +371,8 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
}
}
result = append(result, map[string]any{
"hardware_kind": candidate.HardwareKind,
"reader_name": candidate.ReaderName,
"discovery_key": entry.ID,
"control_path": controlPath,
"net_interface": candidate.NetworkInterface,
@@ -374,10 +384,10 @@ func (s *Server) handleDiscoveredDevices(w http.ResponseWriter, r *http.Request)
"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 != "",
"network_capable": candidate.HardwareKind != "pcsc" && (candidate.NetworkInterface != "" || candidate.QMIControl != ""),
"configured": configuredID != "",
"configured_id": configuredID,
"degraded": !candidate.HasATPort(),
"degraded": candidate.HardwareKind != "pcsc" && !candidate.HasATPort(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"data": map[string]any{"devices": result}})
@@ -459,7 +469,16 @@ func (s *Server) handleDevicePath(
if next.Name == id && strings.TrimSpace(payload.Name) == "" {
next.Name = config.Name
}
if next.SIMPIN == "" || next.SIMPIN == store.SecretMask {
next.SIMPIN = config.SIMPIN
}
if _, physicalID, present := s.physicalForConfig(next); present {
if pinSetter, ok := s.devices.(interface{ SetSIMPin(string, string) error }); ok {
if err := pinSetter.SetSIMPin(physicalID, next.SIMPIN); err != nil {
s.writeDeviceError(w, err)
return true
}
}
if selector, ok := s.devices.(interface{ SetBackend(string, string) error }); ok {
if err := selector.SetBackend(physicalID, next.DeviceBackend); err != nil {
s.writeDeviceError(w, err)
@@ -482,6 +501,16 @@ func (s *Server) handleDevicePath(
}
entry, physicalID, physicalPresent := s.physicalForConfig(config)
if config.DeviceType == store.DeviceTypeUSBSIMReader && len(tail) > 0 {
operation := strings.Join(tail, "/")
unsupported := tail[0] == "network" || tail[0] == "operator_selection" ||
operation == "actions/at" || operation == "actions/ussd" || operation == "actions/ussd/continue" ||
operation == "actions/ussd/cancel" || operation == "actions/reboot" || operation == "usbnet-mode"
if unsupported {
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers support WiFi Calling, IMS SMS and calls only")
return true
}
}
if len(tail) > 0 && tail[0] == "esim" {
return s.handleESIM(w, r, tail[1:], physicalID, physicalPresent, config.ID)
}
@@ -1803,6 +1832,10 @@ func deviceStatus(entry device.Device) map[string]any {
}
func storedDeviceConfig(config store.Device) map[string]any {
simPIN := ""
if strings.TrimSpace(config.SIMPIN) != "" {
simPIN = store.SecretMask
}
return map[string]any{
"id": config.ID,
"name": config.Name,
@@ -1813,6 +1846,7 @@ func storedDeviceConfig(config store.Device) map[string]any {
"usb_path": config.USBPath,
"audio_device": config.AudioDevice,
"modem_imei": config.ModemIMEI,
"sim_pin": simPIN,
"apn": config.APN,
"proxy_port": config.ProxyPort,
"baud_rate": config.BaudRate,
@@ -1832,6 +1866,17 @@ func storedDeviceConfig(config store.Device) map[string]any {
func fillConfigFromPhysical(config *store.Device, entry device.Device) {
candidate := entry.Candidate
if candidate.HardwareKind == "pcsc" {
config.DeviceType = store.DeviceTypeUSBSIMReader
config.ControlDevice = candidate.ReaderName
config.ATPort = ""
config.Interface = ""
config.DeviceBackend = "pcsc"
config.ESIMTransport = "pcsc"
config.NetworkEnabled = false
config.SMSEnabled = true
config.VoWiFiEnabled = true
}
if config.Interface == "" {
config.Interface = candidate.NetworkInterface
}
@@ -1914,13 +1959,29 @@ func modemSummary(snapshot *device.Snapshot, phone string, phoneSource string) m
"reg_status": snapshot.RegistrationStatus,
"reg_status_text": registrationText(snapshot),
"ps_attached": snapshot.PSAttached,
"sim_inserted": snapshot.SIMStatus != "",
"sim_inserted": snapshotHasSIM(snapshot),
"operating_mode": snapshot.OperatingMode,
"phone_number": phone,
"phone_number_source": phoneSource,
}
}
func snapshotHasSIM(snapshot *device.Snapshot) bool {
if snapshot == nil {
return false
}
if snapshot.SIMReady || strings.TrimSpace(snapshot.ICCID) != "" || strings.TrimSpace(snapshot.IMSI) != "" {
return true
}
switch strings.ToLower(strings.TrimSpace(snapshot.SIMStatus)) {
case "", "unknown", "not_inserted", "not inserted", "absent":
return false
default:
// PIN/PUK and other explicit UICC states prove that a card is present.
return true
}
}
func idleVoWiFiRuntime(id string, snapshot *device.Snapshot) map[string]any {
iccid := ""
imsi := ""
@@ -1970,6 +2031,9 @@ func deviceName(entry device.Device) string {
}
func backendMode(candidate modem.Candidate) string {
if candidate.HardwareKind == "pcsc" {
return "pcsc"
}
if candidate.QMIControl != "" {
return "qmi"
}
+21
View File
@@ -115,3 +115,24 @@ func TestConfiguredDeviceSummaryMarksIdleRuntimeAsNotInUse(t *testing.T) {
t.Fatalf("summary = %#v", got)
}
}
func TestSnapshotHasSIMDoesNotTreatUnknownStatusAsInserted(t *testing.T) {
for _, snapshot := range []*device.Snapshot{
{IMEI: "867123456789012"},
{IMEI: "867123456789012", SIMStatus: "unknown"},
{IMEI: "867123456789012", SIMStatus: "not_inserted"},
} {
if snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported with a SIM: %#v", snapshot)
}
}
for _, snapshot := range []*device.Snapshot{
{SIMStatus: "pin_required"},
{ICCID: "89441000400128014257"},
{SIMReady: true},
} {
if !snapshotHasSIM(snapshot) {
t.Fatalf("snapshot was reported without a SIM: %#v", snapshot)
}
}
}
+21
View File
@@ -1,11 +1,13 @@
package server
import (
"context"
"errors"
"net/http"
"strings"
"vocat/internal/exportproxy"
"vocat/internal/store"
)
func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cleanPath string) bool {
@@ -33,6 +35,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
created, err := s.exportProxy.Create(r.Context(), config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -71,6 +76,9 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return true
}
if s.rejectUnsupportedExportProxyDevice(w, r.Context(), config.DeviceID) {
return true
}
updated, err := s.exportProxy.Update(r.Context(), id, config)
if err != nil {
s.writeExportProxyError(w, err)
@@ -90,6 +98,19 @@ func (s *Server) routeExportProxyAPI(w http.ResponseWriter, r *http.Request, cle
return true
}
func (s *Server) rejectUnsupportedExportProxyDevice(w http.ResponseWriter, ctx context.Context, deviceID string) bool {
config, err := s.store.Device(ctx, strings.TrimSpace(deviceID))
if err != nil {
s.writeStoreError(w, err)
return true
}
if config.DeviceType == store.DeviceTypeUSBSIMReader {
writeError(w, http.StatusConflict, "wifi_calling_only_device", "USB SIM readers cannot export cellular data as a proxy")
return true
}
return false
}
func (s *Server) writeExportProxyError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, exportproxy.ErrDisabled):
@@ -0,0 +1,31 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"vocat/internal/store"
)
func TestExportProxyRejectsUSBSIMReader(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: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
server := &Server{store: database}
response := httptest.NewRecorder()
if !server.rejectUnsupportedExportProxyDevice(response, context.Background(), "reader-1") {
t.Fatal("reader was accepted as an export-proxy device")
}
if response.Code != http.StatusConflict {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
}
+25
View File
@@ -93,3 +93,28 @@ func TestProfileProxyBindingRejectsSameICCIDOnDifferentProxy(t *testing.T) {
t.Fatalf("binding after rejected rebind = %+v, %v", binding, err)
}
}
func TestProfileProxyBindingSupportsPCSCReader(t *testing.T) {
server, database, controller := newProfileBindingTestServer(t)
readerICCID := "89104100000028106378"
if err := database.UpsertDevice(context.Background(), store.Device{
ID: "reader-1", Name: "USB SIM Reader", DeviceType: store.DeviceTypeUSBSIMReader,
}); err != nil {
t.Fatal(err)
}
controller.state = vowifi.State{DeviceID: "reader-1", ICCID: readerICCID, Enabled: true}
response := profileBindingRequest(t, server, http.MethodPost, `{
"upstream_proxy_id":"route-1",
"bindings":[{"device_id":"reader-1","iccid":"89104100000028106378","profile_name":"Reader Profile"}]
}`)
if response.Code != http.StatusOK {
t.Fatalf("POST status = %d, body = %s", response.Code, response.Body.String())
}
binding, err := database.DeviceProxyBinding(context.Background(), readerICCID)
if err != nil || binding.DeviceID != "reader-1" || binding.UpstreamProxyID != "route-1" {
t.Fatalf("reader binding = %+v, %v", binding, err)
}
if controller.reconnects != 1 {
t.Fatalf("reader reconnects = %d, want 1", controller.reconnects)
}
}
+49 -4
View File
@@ -40,6 +40,7 @@ var notificationChannels = []string{
"webhook",
"bark",
"pushplus",
"wecom",
}
var notificationFields = map[string]map[string]string{
@@ -61,6 +62,9 @@ var notificationFields = map[string]map[string]string{
"pushplus": {
"token": "string", "topic": "string", "channel": "string",
},
"wecom": {
"urls": "strings", "payload_template": "string",
},
}
// routeSettingsAPI is intentionally independent of the main router so it can
@@ -291,6 +295,11 @@ func validateNotificationField(
return fmt.Errorf("%s is not a valid email address", field)
}
}
if channel == "wecom" && name == "payload_template" && value != "" {
if _, err := renderWecomPayload(value, wecomTestValues(time.Unix(0, 0))); err != nil {
return fmt.Errorf("%s is not a valid JSON template: %w", field, err)
}
}
case "integer":
var value int
if err := json.Unmarshal(raw, &value); err != nil {
@@ -324,6 +333,9 @@ func validateNotificationField(
return fmt.Errorf("%s contains an invalid value", field)
}
if name == "urls" {
if channel == "wecom" && value == store.SecretMask {
continue
}
if _, err := parseOutboundURL(value, false); err != nil {
return fmt.Errorf("%s contains an invalid HTTP URL", field)
}
@@ -375,7 +387,7 @@ func (s *Server) handleNotificationTest(
writeError(w, http.StatusNotFound, "not_found", "notification channel was not found")
return
}
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" {
if channel != "webhook" && channel != "telegram" && channel != "email" && channel != "bark" && channel != "wecom" {
writeError(
w,
http.StatusNotImplemented,
@@ -426,6 +438,8 @@ func (s *Server) handleNotificationTest(
err = sendEmailNotificationTest(r.Context(), resolved)
case "bark":
err = sendBarkNotificationTest(r.Context(), resolved)
case "wecom":
err = sendWecomNotificationTest(r.Context(), resolved)
}
if err != nil {
redacted := store.RedactText(err.Error(), provider)
@@ -503,9 +517,7 @@ func (s *Server) resolveNotificationTestConfig(
}
for key, value := range overlay {
if _, secret := sensitive[key]; secret {
if text, ok := value.(string); !ok || text == "" || text == store.SecretMask {
continue
}
value = mergeNotificationTestSecretValue(value, resolved[key])
}
resolved[key] = value
}
@@ -531,6 +543,37 @@ func (s *Server) resolveNotificationTestConfig(
return resolved, provider, nil
}
// mergeNotificationTestSecretValue preserves masked values submitted by the
// settings form while allowing newly entered sensitive values in the same
// request. WeCom URLs are a sensitive list, unlike the string-based secrets
// used by the other notification channels.
func mergeNotificationTestSecretValue(incoming, existing any) any {
if incoming == nil {
return existing
}
switch next := incoming.(type) {
case string:
if next == "" || next == store.SecretMask {
return existing
}
case []any:
previous, ok := existing.([]any)
if !ok {
return incoming
}
merged := make([]any, len(next))
for index, value := range next {
if index < len(previous) {
merged[index] = mergeNotificationTestSecretValue(value, previous[index])
} else {
merged[index] = value
}
}
return merged
}
return incoming
}
func validateNotificationTestConfig(channel string, config map[string]any) error {
switch channel {
case "webhook":
@@ -549,6 +592,8 @@ func validateNotificationTestConfig(channel string, config map[string]any) error
if len(urls) > 8 {
return errors.New("bark test is limited to 8 URLs")
}
case "wecom":
return validateWecomNotificationConfig(config)
case "telegram":
token := configString(config, "bot_token")
if token == "" || token == store.SecretMask {
+97
View File
@@ -135,6 +135,103 @@ func TestNotificationSettingsAlwaysReturnsFiveChannelsAndPreservesSecrets(t *tes
}
}
func TestWecomNotificationSettingsPreserveWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
webhookURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=wecom-secret"
template := `{"msgtype":"text","text":{"content":{{message}}}}`
first, err := json.Marshal(map[string]any{
"wecom": map[string]any{
"enabled": true, "urls": []string{webhookURL}, "payload_template": template,
},
})
if err != nil {
t.Fatal(err)
}
recorder := test.request(t, http.MethodPut, "/api/settings/notifications", string(first))
if recorder.Code != http.StatusOK {
t.Fatalf("first PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
if bytes.Contains(recorder.Body.Bytes(), []byte("wecom-secret")) {
t.Fatalf("PUT response leaked webhook URL: %s", recorder.Body)
}
response := decodeSettingsResponse(t, recorder)
wecom := response["data"].(map[string]any)["wecom"].(map[string]any)
urls, ok := wecom["urls"].([]any)
if !ok || len(urls) != 1 || urls[0] != store.SecretMask {
t.Fatalf("redacted WeCom URLs = %#v", wecom["urls"])
}
second, err := json.Marshal(map[string]any{
"wecom": map[string]any{
"enabled": true, "urls": []string{store.SecretMask}, "payload_template": template,
},
})
if err != nil {
t.Fatal(err)
}
recorder = test.request(t, http.MethodPut, "/api/settings/notifications", string(second))
if recorder.Code != http.StatusOK {
t.Fatalf("masked PUT status = %d, body = %s", recorder.Code, recorder.Body)
}
stored, err := test.database.NotificationSetting(context.Background(), "wecom")
if err != nil || !bytes.Contains(stored.Config, []byte("wecom-secret")) {
t.Fatalf("stored WeCom config = %s, err = %v", stored.Config, err)
}
}
func TestResolveWecomNotificationTestConfigAcceptsUnsavedWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
raw, err := json.Marshal(map[string]any{
"urls": []string{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
if err != nil {
t.Fatal(err)
}
urls, ok := resolved["urls"].([]any)
if !ok || len(urls) != 1 || urls[0] != "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved" {
t.Fatalf("resolved URLs = %#v", resolved["urls"])
}
}
func TestResolveWecomNotificationTestConfigMergesMaskedAndUnsavedWebhookURLs(t *testing.T) {
test := newSettingsAPITest(t)
storedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=stored"
unsavedURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=unsaved"
storedConfig, err := json.Marshal(map[string]any{
"urls": []string{storedURL},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
if err := test.database.UpsertNotificationSetting(context.Background(), store.NotificationSetting{
Channel: "wecom",
Config: storedConfig,
}); err != nil {
t.Fatal(err)
}
raw, err := json.Marshal(map[string]any{
"urls": []string{store.SecretMask, unsavedURL},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
})
if err != nil {
t.Fatal(err)
}
resolved, _, err := test.server.resolveNotificationTestConfig(context.Background(), "wecom", raw)
if err != nil {
t.Fatal(err)
}
urls, ok := resolved["urls"].([]any)
if !ok || len(urls) != 2 || urls[0] != storedURL || urls[1] != unsavedURL {
t.Fatalf("resolved URLs = %#v", resolved["urls"])
}
}
func TestNotificationSettingsRejectsUnknownAndMalformedInput(t *testing.T) {
test := newSettingsAPITest(t)
cases := []struct {
+11
View File
@@ -546,6 +546,13 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
if onlyDevice != "" && config.ID != onlyDevice {
continue
}
// A PC/SC USB reader has no modem storage or AT command channel. Its
// messages are delivered by the active VoWiFi IMS session, so attempting
// an AT+CMGL catch-up scan would only poison the reader's health state
// with ErrNoATPort.
if !supportsModemSMSStorage(config) {
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
@@ -659,6 +666,10 @@ func (s *Server) syncModemSMS(ctx context.Context, onlyDevice string) {
}
}
func supportsModemSMSStorage(config store.Device) bool {
return store.NormalizeDeviceType(config.DeviceType) != store.DeviceTypeUSBSIMReader
}
func shouldDeferModemSMSSync(state vowifi.State, stateErr error) bool {
if stateErr != nil || !state.Enabled {
return false
+9
View File
@@ -108,6 +108,15 @@ func TestNormalizeSMSDeviceFilter(t *testing.T) {
}
}
func TestSupportsModemSMSStorageRejectsUSBReader(t *testing.T) {
if supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypeUSBSIMReader}) {
t.Fatal("USB SIM reader must not be polled with modem SMS AT commands")
}
if !supportsModemSMSStorage(store.Device{DeviceType: store.DeviceTypePCIeEC20EC25}) {
t.Fatal("cellular modem should retain modem SMS storage synchronization")
}
}
func TestSMSSendOutcome(t *testing.T) {
tests := []struct {
name string
+4 -2
View File
@@ -24,7 +24,7 @@ import (
const smsNotificationPollInterval = 2 * time.Second
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook"}
var smsOnlyNotificationChannels = []string{"bark", "email", "pushplus", "webhook", "wecom"}
type smsNotification struct {
DeviceID string
@@ -143,7 +143,7 @@ func (s *Server) smsNotificationConfig(ctx context.Context, channel string) (map
func validateSMSNotificationConfig(channel string, config map[string]any) error {
switch channel {
case "bark", "email", "webhook":
case "bark", "email", "webhook", "wecom":
if err := validateNotificationTestConfig(channel, config); err != nil {
return err
}
@@ -202,6 +202,8 @@ func sendSMSNotification(ctx context.Context, channel string, config map[string]
return sendPushplusSMSNotification(ctx, config, message)
case "webhook":
return sendWebhookSMSNotification(ctx, config, message)
case "wecom":
return sendWecomNotification(ctx, config, wecomSMSValues(message))
default:
return fmt.Errorf("unsupported SMS notification channel %q", channel)
}
+34
View File
@@ -36,12 +36,46 @@ func TestRenderSMSWebhookTemplate(t *testing.T) {
}
}
func TestWecomSMSValuesIncludeRenderedSMSFields(t *testing.T) {
location := time.FixedZone("UTC+8", 8*60*60)
message := smsNotification{
DeviceID: "device-1", DeviceName: "客厅", DeviceLabel: "EC20",
Number: "+447386", Time: time.Date(2026, 8, 8, 17, 25, 35, 0, location), Content: "hello",
}
values := wecomSMSValues(message)
if values["event"] != "sms.received" || values["title"] != "收到新短信" || values["message"] != message.Text() {
t.Fatalf("common values = %#v", values)
}
wantLocalTime := message.Time.Local().Format("2006-01-02 15:04:05")
if values["content"] != "hello" || values["number"] != "+447386" || values["device_label"] != "EC20" || values["time"] != wantLocalTime {
t.Fatalf("SMS values = %#v", values)
}
}
func TestWecomAutomaticTaskValuesLeaveSMSFieldsEmpty(t *testing.T) {
values := wecomAutomaticTaskValues(automaticTaskNotification{
Title: "自动任务执行成功", Text: "任务已完成", Time: time.Unix(1_700_000_000, 0),
})
if values["event"] != "automatic_task.completed" || values["title"] != "自动任务执行成功" || values["message"] != "任务已完成" {
t.Fatalf("common values = %#v", values)
}
for _, name := range []string{"content", "number", "device_id", "device_name", "device_label", "time"} {
if values[name] != "" {
t.Fatalf("%s = %q, want empty", name, values[name])
}
}
}
func TestValidateSMSNotificationConfig(t *testing.T) {
valid := map[string]map[string]any{
"bark": {"urls": []any{"https://api.day.app/key"}},
"email": {"smtp_host": "smtp.example.com", "from_address": "[email protected]", "to_addresses": []any{"[email protected]"}},
"pushplus": {"token": "secret"},
"webhook": {"urls": []any{"https://example.com/hook"}},
"wecom": {
"urls": []any{"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=secret"},
"payload_template": `{"msgtype":"text","text":{"content":{{message}}}}`,
},
}
for channel, config := range valid {
if err := validateSMSNotificationConfig(channel, config); err != nil {
+154
View File
@@ -0,0 +1,154 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
var wecomTemplateVariableNames = []string{
"event",
"title",
"message",
"timestamp",
"content",
"number",
"device_id",
"device_name",
"device_label",
"time",
}
type wecomTemplateValues map[string]string
func renderWecomPayload(template string, values wecomTemplateValues) ([]byte, error) {
for _, name := range wecomTemplateVariableNames {
encoded, err := json.Marshal(values[name])
if err != nil {
return nil, fmt.Errorf("encode WeCom template value %q: %w", name, err)
}
template = strings.ReplaceAll(template, "{{"+name+"}}", string(encoded))
}
if strings.Contains(template, "{{") {
return nil, errors.New("wecom.payload_template contains an unsupported variable")
}
var payload map[string]json.RawMessage
if err := json.Unmarshal([]byte(template), &payload); err != nil || len(payload) == 0 {
return nil, errors.New("wecom.payload_template must render to a non-empty JSON object")
}
return []byte(template), nil
}
func validateWecomResponse(status int, body []byte) error {
var result struct {
ErrCode *int `json:"errcode"`
}
if status < http.StatusOK || status >= http.StatusMultipleChoices ||
json.Unmarshal(body, &result) != nil || result.ErrCode == nil || *result.ErrCode != 0 {
return fmt.Errorf("%w: WeCom response was not successful", errProviderRejected)
}
return nil
}
func wecomTestValues(now time.Time) wecomTemplateValues {
return wecomTemplateValues{
"event": "test", "title": "vocat", "message": "vocat notification test",
"timestamp": now.UTC().Format(time.RFC3339),
}
}
func wecomSMSValues(message smsNotification) wecomTemplateValues {
return wecomTemplateValues{
"event": "sms.received",
"title": "收到新短信",
"message": message.Text(),
"timestamp": message.Time.UTC().Format(time.RFC3339),
"content": message.Content,
"number": message.Number,
"device_id": message.DeviceID,
"device_name": message.DeviceName,
"device_label": message.DeviceLabel,
"time": message.Time.Local().Format("2006-01-02 15:04:05"),
}
}
func wecomAutomaticTaskValues(message automaticTaskNotification) wecomTemplateValues {
return wecomTemplateValues{
"event": "automatic_task.completed",
"title": message.Title,
"message": message.Text,
"timestamp": message.Time.UTC().Format(time.RFC3339),
"content": "",
"number": "",
"device_id": "",
"device_name": "",
"device_label": "",
"time": "",
}
}
func validateWecomNotificationConfig(config map[string]any) error {
urls := configStrings(config, "urls")
if len(urls) == 0 {
return errors.New("wecom.urls must contain at least one URL")
}
if len(urls) > 8 {
return errors.New("wecom.urls cannot contain more than 8 URLs")
}
template := configString(config, "payload_template")
if template == "" {
return errors.New("wecom.payload_template is required")
}
_, err := renderWecomPayload(template, wecomTestValues(time.Unix(0, 0)))
return err
}
func sendWecomNotification(ctx context.Context, config map[string]any, values wecomTemplateValues) error {
payload, err := renderWecomPayload(configString(config, "payload_template"), values)
if err != nil {
return err
}
client, err := restrictedHTTPClient(ctx, 8*time.Second, "")
if err != nil {
return err
}
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 WeCom notification request: %w", err)
}
request.Header.Set("Content-Type", "application/json; charset=utf-8")
request.Header.Set("User-Agent", "vocat-wecom-notification/1")
response, err := client.Do(request)
if err != nil {
return fmt.Errorf("send WeCom notification: %w", err)
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64<<10))
closeErr := response.Body.Close()
if readErr != nil {
return fmt.Errorf("read WeCom response: %w", readErr)
}
if closeErr != nil {
return fmt.Errorf("close WeCom response: %w", closeErr)
}
if err := validateWecomResponse(response.StatusCode, body); err != nil {
return err
}
}
return nil
}
func sendWecomNotificationTest(ctx context.Context, config map[string]any) error {
return sendWecomNotification(ctx, config, wecomTestValues(time.Now()))
}
@@ -0,0 +1,56 @@
package server
import (
"errors"
"net/http"
"testing"
)
func TestRenderWecomPayloadEscapesTemplateValues(t *testing.T) {
payload, err := renderWecomPayload(
`{"msgtype":"text","text":{"content":{{message}},"number":{{number}}}}`,
wecomTemplateValues{
"message": "quote: \"\nline",
"number": "+447386",
},
)
if err != nil {
t.Fatal(err)
}
if got, want := string(payload), `{"msgtype":"text","text":{"content":"quote: \"\nline","number":"+447386"}}`; got != want {
t.Fatalf("payload = %s, want %s", got, want)
}
}
func TestRenderWecomPayloadRejectsInvalidTemplate(t *testing.T) {
for _, template := range []string{
`{"text":{{unknown}}}`,
`[]`,
`{"msgtype":"text"`,
} {
t.Run(template, func(t *testing.T) {
if _, err := renderWecomPayload(template, wecomTemplateValues{}); err == nil {
t.Fatalf("template %q was accepted", template)
}
})
}
}
func TestValidateWecomResponse(t *testing.T) {
if err := validateWecomResponse(http.StatusOK, []byte(`{"errcode":0,"errmsg":"ok"}`)); err != nil {
t.Fatalf("successful response = %v", err)
}
for _, response := range []struct {
status int
body string
}{
{http.StatusBadGateway, `{"errcode":0}`},
{http.StatusOK, `{"errcode":40058,"errmsg":"invalid"}`},
{http.StatusOK, `{}`},
{http.StatusOK, `not-json`},
} {
if err := validateWecomResponse(response.status, []byte(response.body)); !errors.Is(err, errProviderRejected) {
t.Fatalf("validateWecomResponse(%d, %s) = %v", response.status, response.body, err)
}
}
}
+24 -7
View File
@@ -17,6 +17,7 @@ const (
DeviceTypeWiFi410 = "wifi_410"
DeviceTypeDJI4G = "dji_4g"
DeviceTypePCIeEC20EC25 = "pcie_ec20_ec25"
DeviceTypeUSBSIMReader = "usb_sim_reader"
)
// NormalizeDeviceType returns a stable persisted device type identifier.
@@ -27,6 +28,8 @@ func NormalizeDeviceType(value string) string {
return DeviceTypeWiFi410
case DeviceTypeDJI4G:
return DeviceTypeDJI4G
case DeviceTypeUSBSIMReader:
return DeviceTypeUSBSIMReader
case "", DeviceTypePCIeEC20EC25:
return DeviceTypePCIeEC20EC25
default:
@@ -132,16 +135,29 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
value.DeviceBackend = "at"
}
value.DeviceBackend = strings.ToLower(strings.TrimSpace(value.DeviceBackend))
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" {
if value.DeviceBackend != "at" && value.DeviceBackend != "qmi" && value.DeviceBackend != "pcsc" {
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" {
if value.ESIMTransport != "at" && value.ESIMTransport != "qmi" && value.ESIMTransport != "pcsc" && value.ESIMTransport != "none" {
return fmt.Errorf("unsupported eSIM transport %q", value.ESIMTransport)
}
value.SIMPIN = strings.TrimSpace(value.SIMPIN)
if value.SIMPIN != "" {
if len(value.SIMPIN) < 4 || len(value.SIMPIN) > 8 || strings.Trim(value.SIMPIN, "0123456789") != "" {
return errors.New("SIM PIN must contain 4 to 8 digits")
}
}
if value.DeviceType == DeviceTypeUSBSIMReader {
value.DeviceBackend = "pcsc"
value.ESIMTransport = "pcsc"
value.NetworkEnabled = false
value.SMSEnabled = true
value.VoWiFiEnabled = true
}
extra, err := normalizeJSONObject(value.Extra)
if err != nil {
return fmt.Errorf("normalize device extra data: %w", err)
@@ -159,12 +175,12 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
_, err = executor.ExecContext(ctx, `
INSERT INTO devices (
id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate,
audio_device, modem_imei, sim_pin, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
device_type = excluded.device_type,
@@ -174,6 +190,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
usb_path = excluded.usb_path,
audio_device = excluded.audio_device,
modem_imei = excluded.modem_imei,
sim_pin = excluded.sim_pin,
apn = excluded.apn,
proxy_port = excluded.proxy_port,
baud_rate = excluded.baud_rate,
@@ -192,7 +209,7 @@ func upsertDevice(ctx context.Context, executor contextExecer, value Device) err
updated_at = excluded.updated_at
`,
value.ID, value.Name, value.DeviceType, value.Interface, value.ControlDevice, value.ATPort,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.APN,
value.USBPath, value.AudioDevice, value.ModemIMEI, value.SIMPIN, value.APN,
value.ProxyPort, value.BaudRate, value.DataBits, value.StopBits,
value.Parity, value.DeviceBackend, value.ESIMTransport,
boolInt(value.QMIUseProxy), value.QMIProxyPath, value.QMIProxyExecutable,
@@ -282,7 +299,7 @@ func (s *Store) DeleteDevice(ctx context.Context, id string) error {
const deviceSelect = `
SELECT id, name, device_type, interface, control_device, at_port, usb_path,
audio_device, modem_imei, apn, proxy_port, baud_rate, data_bits,
audio_device, modem_imei, sim_pin, 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
@@ -295,7 +312,7 @@ func scanDevice(row rowScanner) (Device, error) {
var createdAt, updatedAt int64
err := row.Scan(
&value.ID, &value.Name, &value.DeviceType, &value.Interface, &value.ControlDevice,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI,
&value.ATPort, &value.USBPath, &value.AudioDevice, &value.ModemIMEI, &value.SIMPIN,
&value.APN, &value.ProxyPort, &value.BaudRate, &value.DataBits,
&value.StopBits, &value.Parity, &value.DeviceBackend,
&value.ESIMTransport, &qmiUseProxy, &value.QMIProxyPath,
+62
View File
@@ -366,6 +366,31 @@ func TestDeviceStateRoundTripAndCascade(t *testing.T) {
}
}
func TestUSBSIMReaderConfigurationIsWiFiCallingOnly(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
err := database.UpsertDevice(ctx, Device{
ID: "reader-1", Name: "USB SIM", DeviceType: DeviceTypeUSBSIMReader,
USBPath: "1-3", ControlDevice: "Reader 00 00", SIMPIN: "1234",
DeviceBackend: "at", ESIMTransport: "at", NetworkEnabled: true,
})
if err != nil {
t.Fatal(err)
}
got, err := database.Device(ctx, "reader-1")
if err != nil {
t.Fatal(err)
}
if got.DeviceBackend != "pcsc" || got.ESIMTransport != "pcsc" || got.NetworkEnabled || !got.SMSEnabled || !got.VoWiFiEnabled || got.SIMPIN != "1234" {
t.Fatalf("reader config = %+v", got)
}
bad := got
bad.SIMPIN = "12x4"
if err := database.UpsertDevice(ctx, bad); err == nil {
t.Fatal("non-numeric SIM PIN was accepted")
}
}
func TestSMSPersistenceAndDerivedThreads(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
@@ -737,6 +762,43 @@ func TestNotificationAndAppSecretPreservation(t *testing.T) {
}
}
func TestNotificationArraySecretPreservation(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
originalURL := "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=first-secret"
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "wecom", Enabled: true,
Config: json.RawMessage(`{"urls":["` + originalURL + `"]}`),
}); err != nil {
t.Fatal(err)
}
setting, err := database.NotificationSetting(ctx, "wecom")
if err != nil {
t.Fatal(err)
}
var redacted map[string]any
if err := json.Unmarshal(setting.Redacted().Config, &redacted); err != nil {
t.Fatal(err)
}
urls, ok := redacted["urls"].([]any)
if !ok || len(urls) != 1 || urls[0] != SecretMask {
t.Fatalf("redacted URLs = %#v", redacted["urls"])
}
if err := database.UpsertNotificationSetting(ctx, NotificationSetting{
Channel: "wecom", Enabled: true,
Config: json.RawMessage(`{"urls":["` + SecretMask + `","https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=second-secret"]}`),
}); err != nil {
t.Fatal(err)
}
setting, err = database.NotificationSetting(ctx, "wecom")
if err != nil {
t.Fatal(err)
}
if !bytes.Contains(setting.Config, []byte(originalURL)) || !bytes.Contains(setting.Config, []byte("second-secret")) {
t.Fatalf("stored URLs = %s", setting.Config)
}
}
func TestEventsPoliciesAndTraffic(t *testing.T) {
ctx := context.Background()
database := openTestStore(t, ":memory:")
+4
View File
@@ -260,6 +260,10 @@ func migrationStatements(version int) []string {
`ALTER TABLE card_policies
ADD COLUMN custom_phone_number TEXT NOT NULL DEFAULT ''`,
}
case 16:
return []string{
`ALTER TABLE devices ADD COLUMN sim_pin TEXT NOT NULL DEFAULT ''`,
}
default:
return nil
}
+49 -12
View File
@@ -24,6 +24,7 @@ type Device struct {
USBPath string
AudioDevice string
ModemIMEI string
SIMPIN string
APN string
ProxyPort int
BaudRate int
@@ -339,12 +340,9 @@ func (value NotificationSetting) SensitiveValues() []string {
}
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)
}
collectJSONStringValues(getJSONPath(document, field), &values)
}
return values
return uniqueNonemptyStrings(values)
}
type AppSetting struct {
@@ -576,8 +574,8 @@ func redactJSONFields(value json.RawMessage, fields []string, replacement string
return json.RawMessage(`{}`)
}
for _, field := range fields {
if getJSONPath(document, field) != nil {
setJSONPath(document, field, replacement)
if current := getJSONPath(document, field); current != nil {
setJSONPath(document, field, redactJSONValue(current, replacement))
}
}
encoded, err := json.Marshal(document)
@@ -602,16 +600,55 @@ func mergeJSONSecrets(
}
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)
}
if previous := getJSONPath(current, field); previous != nil {
setJSONPath(next, field, mergeJSONSecretValue(value, previous))
}
}
return json.Marshal(next)
}
func redactJSONValue(value any, replacement string) any {
switch typed := value.(type) {
case string:
return replacement
case []any:
result := make([]any, len(typed))
for index, item := range typed {
result[index] = redactJSONValue(item, replacement)
}
return result
default:
return replacement
}
}
func mergeJSONSecretValue(incoming, existing any) any {
if incoming == nil {
return existing
}
switch next := incoming.(type) {
case string:
if next == "" || next == SecretMask {
return existing
}
case []any:
previous, ok := existing.([]any)
if !ok {
return incoming
}
merged := make([]any, len(next))
for index, value := range next {
if index < len(previous) {
merged[index] = mergeJSONSecretValue(value, previous[index])
} else {
merged[index] = value
}
}
return merged
}
return incoming
}
func getJSONPath(document map[string]any, path string) any {
if strings.TrimSpace(path) == "" {
return nil
+2
View File
@@ -22,6 +22,8 @@ func DefaultNotificationSensitiveFields(channel string) []string {
return []string{"secret"}
case "pushplus":
return []string{"token"}
case "wecom":
return []string{"urls"}
default:
return nil
}
+3 -2
View File
@@ -13,7 +13,7 @@ import (
_ "modernc.org/sqlite"
)
const schemaVersion = 15
const schemaVersion = 16
var ErrNotFound = errors.New("store: not found")
@@ -122,7 +122,8 @@ func migrate(ctx context.Context, db *sql.DB) error {
// migration are still safe and must be applied.
duplicateAdditiveColumn := (nextVersion == 7 && strings.Contains(statement, "ADD COLUMN modem_imei")) ||
(nextVersion == 8 && strings.Contains(statement, "ADD COLUMN device_type")) ||
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN"))
(nextVersion == 14 && strings.Contains(statement, "ADD COLUMN")) ||
(nextVersion == 16 && strings.Contains(statement, "ADD COLUMN sim_pin"))
if duplicateAdditiveColumn && strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
continue
}
+124
View File
@@ -0,0 +1,124 @@
package vowifi
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"vocat/internal/pcsc"
)
type PCSCBindingResolver func(context.Context, string) (pcsc.Selector, string, error)
// PCSCAdapter uses a directly attached USB smart-card reader as the UICC for
// Wi-Fi Calling. It deliberately exposes no cellular-radio behaviour.
type PCSCAdapter struct {
service *pcsc.Service
resolve PCSCBindingResolver
mu sync.RWMutex
bindings map[string]string
}
var (
_ SIMIdentityReader = (*PCSCAdapter)(nil)
_ SMSCenterReader = (*PCSCAdapter)(nil)
_ AKAProvider = (*PCSCAdapter)(nil)
_ RadioController = (*PCSCAdapter)(nil)
)
func NewPCSCAdapter(service *pcsc.Service, resolver PCSCBindingResolver) (*PCSCAdapter, error) {
if service == nil || resolver == nil {
return nil, errors.New("vocat: PC/SC service and reader resolver are required")
}
return &PCSCAdapter{service: service, resolve: resolver, bindings: make(map[string]string)}, nil
}
func (adapter *PCSCAdapter) ReadIdentity(ctx context.Context, deviceID string) (SIMIdentity, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return SIMIdentity{}, err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return SIMIdentity{}, fmt.Errorf("read USB SIM identity: %w", err)
}
if len(identity.IMSI) < 5 {
return SIMIdentity{}, errors.New("vocat: USB SIM reader returned an invalid IMSI")
}
adapter.mu.Lock()
adapter.bindings[identity.ICCID] = strings.TrimSpace(deviceID)
adapter.mu.Unlock()
mncLength := identity.MNCLength
if mncLength != 2 && mncLength != 3 {
if mcc, mnc, ok := assignedHomePLMN(identity.IMSI); ok {
return SIMIdentity{ICCID: identity.ICCID, IMSI: identity.IMSI, HomeMCC: mcc, HomeMNC: mnc, SMSC: identity.SMSC}, nil
}
return SIMIdentity{}, ErrEC20MNCUnavailable
}
if len(identity.IMSI) < 3+mncLength {
return SIMIdentity{}, errors.New("vocat: USB SIM IMSI is shorter than its EF_AD home PLMN")
}
return SIMIdentity{
ICCID: identity.ICCID, IMSI: identity.IMSI,
HomeMCC: identity.IMSI[:3], HomeMNC: identity.IMSI[3 : 3+mncLength],
SMSC: identity.SMSC,
}, nil
}
func (adapter *PCSCAdapter) ReadSMSCenter(ctx context.Context, deviceID string) (string, error) {
selector, pin, err := adapter.resolve(ctx, strings.TrimSpace(deviceID))
if err != nil {
return "", err
}
identity, err := adapter.service.ReadIdentity(ctx, selector, pin)
if err != nil {
return "", err
}
if strings.TrimSpace(identity.SMSC) == "" {
return "", errors.New("vocat: USB SIM does not expose a service-centre address")
}
return identity.SMSC, nil
}
func (adapter *PCSCAdapter) CheckReady(ctx context.Context, identity SIMIdentity) (AKAEvidence, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAEvidence{}, err
}
aid, err := adapter.service.CheckReady(ctx, selector, identity.ICCID, pin)
if err != nil {
return AKAEvidence{}, fmt.Errorf("check USB SIM AKA application: %w", err)
}
return AKAEvidence{Ready: true, Application: aid}, nil
}
func (adapter *PCSCAdapter) deviceID(identity SIMIdentity) string {
adapter.mu.RLock()
deviceID := adapter.bindings[identity.ICCID]
adapter.mu.RUnlock()
return deviceID
}
func (adapter *PCSCAdapter) Authenticate(ctx context.Context, identity SIMIdentity, challenge AKAChallenge) (AKAResult, error) {
selector, pin, err := adapter.resolve(ctx, adapter.deviceID(identity))
if err != nil {
return AKAResult{}, err
}
result, err := adapter.service.Authenticate(ctx, selector, identity.ICCID, pin, pcsc.AKAChallenge(challenge))
if err != nil {
if errors.Is(err, pcsc.ErrAKARejected) {
return AKAResult{}, errors.Join(ErrEC20AKAMACFailure, err)
}
return AKAResult{}, fmt.Errorf("authenticate with USB SIM: %w", err)
}
return AKAResult(result), nil
}
func (*PCSCAdapter) Snapshot(context.Context, string) (RadioSnapshot, error) {
return RadioSnapshot{OperatingMode: 4, PureAirplanePolicy: true}, nil
}
func (*PCSCAdapter) StopCellularData(context.Context, string) error { return nil }
func (*PCSCAdapter) EnterVoWiFiRFOff(context.Context, string) error { return nil }
func (*PCSCAdapter) Restore(context.Context, string, RadioSnapshot) error { return nil }
+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" role="img" aria-label="USB SIM reader">
<defs><linearGradient id="a" x1="0" y1="0" x2="1" y2="1"><stop stop-color="#64748b"/><stop offset="1" stop-color="#1e293b"/></linearGradient></defs>
<rect x="12" y="31" width="104" height="68" rx="14" fill="url(#a)"/>
<rect x="25" y="43" width="55" height="42" rx="7" fill="#0f172a" stroke="#94a3b8" stroke-width="2"/>
<path d="M40 51h24l8 8v18H40z" fill="#facc15"/><path d="M44 60h24M50 54v23M60 54v23" stroke="#a16207" stroke-width="2"/>
<rect x="91" y="51" width="25" height="29" rx="4" fill="#cbd5e1"/>
<path d="M98 58h11M98 65h11M98 72h11" stroke="#64748b" stroke-width="3"/>
<circle cx="101" cy="89" r="3" fill="#22c55e"/>
</svg>

After

Width:  |  Height:  |  Size: 752 B

@@ -15,9 +15,10 @@ export interface CardPolicyPanelProps {
policy: CardPolicy | null;
deviceOnline: boolean;
onPolicyChanged: () => void | Promise<void>;
wifiCallingOnly?: boolean;
}
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged }: CardPolicyPanelProps) {
export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolicyChanged, wifiCallingOnly = false }: CardPolicyPanelProps) {
const { t } = useI18n();
const operable = deviceOnline && !!iccid;
const currentPolicy = policy?.iccid === iccid ? policy : null;
@@ -66,7 +67,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
<div>
<div className="text-lg font-bold text-gray-900 dark:text-white">{t("卡策略")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{wifiCallingOnly ? t("USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存") : t("VoWiFi / 飞行模式 开关跟着 SIM 卡走,切换即时生效")}</div>
</div>
</div>
{!iccid ? (
@@ -119,7 +120,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
</div>
</div>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-2">
<PolicySwitchCard
<PolicySwitchCard
title="VoWiFi"
subtitle={t("启用时强制关闭蜂窝射频;关闭 VoWiFi 后仍保持飞行模式")}
tone="orange"
@@ -129,7 +130,7 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
failed={toggles.vowifiFailed}
onToggle={toggles.onVoWiFiToggle}
/>
<PolicySwitchCard
{!wifiCallingOnly ? <PolicySwitchCard
title={t("飞行模式")}
subtitle={t("只有手动关闭此开关才允许设备连接基站")}
tone="indigo"
@@ -138,15 +139,15 @@ export function CardPolicyPanel({ deviceId, iccid, policy, deviceOnline, onPolic
pending={toggles.airplanePending}
failed={toggles.airplaneFailed}
onToggle={toggles.onAirplaneToggle}
/>
/> : null}
</div>
<CardPolicyAPN
{!wifiCallingOnly ? <CardPolicyAPN
deviceId={deviceId}
iccid={iccid}
policy={currentPolicy}
deviceOnline={deviceOnline}
onSaved={onPolicyChanged}
/>
/> : null}
</div>
) : null}
</div>
@@ -30,7 +30,7 @@ function isQmiMode(d?: DiscoveredDevice | null): boolean {
}
function modeLabel(d?: DiscoveredDevice | null): string {
const m = String(d?.mode || "unknown").toLowerCase();
return m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
return m === "pcsc" ? "PC/SC" : m === "qmi" ? "QMI" : m === "mbim" ? "MBIM" : m === "ecm" ? "ECM" : m === "rndis" ? "RNDIS" : m === "ncm" ? "NCM" : "UNKNOWN";
}
function Field({ label, children }: { label: ReactNode; children: ReactNode }) {
@@ -47,6 +47,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
const { addSelected, addConfig } = props;
const fixedQmi = isQmiControl(addSelected?.controlPath || addConfig?.controlDevice);
const isMbim = String(addSelected?.mode || "").toLowerCase() === "mbim";
const isReader = addSelected?.hardwareKind === "pcsc" || String(addSelected?.mode || "").toLowerCase() === "pcsc";
useEffect(() => {
if (fixedQmi && addConfig.deviceBackend !== "qmi") props.onConfigChange({ ...addConfig, deviceBackend: "qmi" });
@@ -57,7 +58,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isMbim]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -161,6 +162,9 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
<Field label={t("控制设备")}>
<Input value={addConfig.controlDevice} disabled />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={addConfig.simPin} onChange={(e) => set({ simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3">
<div>
<div className="text-sm font-bold text-gray-800">{t("设备后端模式")}</div>
@@ -173,7 +177,7 @@ export function DeviceAddDialog(props: DeviceAddDialogProps) {
onChange={(v) => set({ deviceBackend: v })}
className="w-[110px]"
placeholder="AT"
disabled={fixedQmi || isMbim}
disabled={fixedQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -34,13 +34,14 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
const usbPath = deviceStatus?.usbPath || editConfig?.usbPath;
const isQmi = isQmiControl(controlDevice);
const isMbim = String(editConfig?.deviceBackend || "").toLowerCase() === "mbim";
const isReader = editConfig?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isQmi && editConfig && editConfig.deviceBackend !== "qmi") onEditConfig({ ...editConfig, deviceBackend: "qmi" });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isQmi]);
const backendOptions = [
const backendOptions = isReader ? [{ value: "pcsc", label: "PC/SC" }] : [
...(isMbim
? []
: [
@@ -106,6 +107,9 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
<Field label={t("控制设备")}>
<Input value={controlDevice || ""} disabled placeholder={t("由系统自动探测")} />
</Field>
{isReader ? <Field label="SIM PIN">
<Input type="password" value={editConfig.simPin || ""} onChange={(e) => onEditConfig({ ...editConfig, simPin: e.target.value })} maxLength={8} inputMode="numeric" placeholder={t("留空表示不修改;仅在 SIM 启用 PIN 时填写")} />
</Field> : null}
<div className="ui-panel-muted space-y-2 p-3">
<div className="flex items-center justify-between">
<div>
@@ -123,7 +127,7 @@ export function DeviceConfigTab({ editConfig, deviceStatus, saving, deleting, on
onChange={(v) => onEditConfig({ ...editConfig, deviceBackend: v as DeviceConfig["deviceBackend"] })}
className="w-[120px]"
placeholder="AT"
disabled={isQmi || isMbim}
disabled={isQmi || isMbim || isReader}
options={backendOptions}
/>
</div>
@@ -15,6 +15,7 @@ export interface DeviceDetailHeaderProps {
onReconnectVowifi: () => void;
onRebootModem: () => void;
onOpenSms: () => void;
wifiCallingOnly?: boolean;
}
export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
@@ -42,7 +43,7 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
<Button loading={props.reconnectingVoWiFi} onClick={props.onReconnectVowifi} className="ui-glass-border !border-0" icon={<ArrowSyncRegular />}>
{t("重连 VoWiFi")}
</Button>
) : device.developerEnabled ? (
) : device.developerEnabled && !props.wifiCallingOnly ? (
<div
className="ui-glass-border flex h-8 items-center gap-2 rounded-lg px-3 text-sm text-gray-700 dark:text-gray-200"
title={t("蜂窝数据仅进入 Export Proxy 的受保护路由,不会成为主机默认出口")}
@@ -58,9 +59,9 @@ export function DeviceDetailHeader(props: DeviceDetailHeaderProps) {
/>
</div>
) : null}
<Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{!props.wifiCallingOnly ? <Button loading={props.rebooting} onClick={props.onRebootModem} className="ui-glass-border !border-0 hover:!text-red-600" icon={<PowerRegular />}>
{t("重启模组")}
</Button>
</Button> : null}
<Button onClick={props.onOpenSms} className="ui-glass-border !border-0" icon={<ChatRegular />}>
{t("短信")}
</Button>
@@ -26,12 +26,13 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
const { t } = useI18n();
const [operatorOpen, setOperatorOpen] = useState(false);
const { device } = props;
const wifiCallingOnly = device.deviceType === "usb_sim_reader";
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className={`grid grid-cols-1 gap-4 ${wifiCallingOnly ? "lg:grid-cols-2" : "lg:grid-cols-3"}`}>
<div className="ui-panel-muted p-4">
<div className="mb-3 text-xs font-bold uppercase tracking-wider text-gray-500">{t("运行状态")}</div>
{isVoWiFiInUse(device) ? (
{isVoWiFiInUse(device) && !(device.modem?.imei && device.modem?.simInserted === false) ? (
<OverviewVowifiCard device={device} />
) : (
<OverviewNetworkCard device={device} onOpenOperatorSelection={() => setOperatorOpen(true)} />
@@ -44,16 +45,16 @@ export function DeviceOverviewTab(props: DeviceOverviewTabProps) {
e911Starting={props.e911Starting}
onSetupE911={props.onSetupE911}
/>
<OverviewNetworkPanel
{!wifiCallingOnly ? <OverviewNetworkPanel
device={device}
trafficMinuteRx={props.trafficMinuteRx}
trafficMinuteTx={props.trafficMinuteTx}
trafficSpeedRx={props.trafficSpeedRx}
trafficSpeedTx={props.trafficSpeedTx}
/>
/> : null}
</div>
{device.developerEnabled && device.networkEnabled && device.id ? <OverviewTrafficChart deviceId={device.id} /> : null}
{device?.id ? (
{device?.id && !wifiCallingOnly ? (
<OperatorSelectionDialog
open={operatorOpen}
deviceId={device.id}
@@ -26,17 +26,20 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const modem = device.modem;
const online = isDeviceOnline(device);
const cellularRegistered = isRegistered(device);
const simMissing = !!modem?.imei && modem?.simInserted === false;
// A persisted runtime may briefly describe the old session while disable is
// being cleaned up. Desired policy is authoritative for the overview badge.
const vowifiRegistered = !!device.vowifiEnabled && !!(device.vowifiActive || device.vowifiRuntime?.smsReady);
const registered = cellularRegistered || vowifiRegistered;
const registered = !simMissing && (cellularRegistered || vowifiRegistered);
const radioOffForVowifi = vowifiRegistered && (modem?.operatingMode === 0 || modem?.operatingMode === 4 || device.flightMode);
const tone = isRecoveringPhase(device.lifecyclePhase) ? "warning" : online ? (registered ? "success" : "warning") : "danger";
let statusText: string;
const phaseLabel = lifecycleLabel(device.lifecyclePhase);
if (phaseLabel && device.lifecyclePhase !== "online" && device.lifecyclePhase !== "offline") statusText = phaseLabel;
else if (online) {
else if (online) {
if (simMissing) statusText = t("SIM卡未插入");
else
statusText = registered
? ""
: device.registrationStateLabel === "searching"
@@ -49,7 +52,9 @@ export function OverviewNetworkCard({ device, onOpenOperatorSelection }: { devic
const level = signalLevel(modem?.signalDbm);
const sigTone = signalTone(modem?.signalDbm);
const netMode = [modem?.networkDuplex, modem?.networkMode].filter(Boolean).join(" ");
const cellularRegistrationText = modem?.regStatus === 5
const cellularRegistrationText = simMissing
? t("SIM卡未插入")
: modem?.regStatus === 5
? t("已驻网(漫游)")
: modem?.regStatus === 1
? t("已驻网")
+1
View File
@@ -44,6 +44,7 @@ export interface AddDeviceForm {
atPort: string;
controlDevice: string;
deviceBackend: string;
simPin: string;
}
export interface LoadError {
+51 -1
View File
@@ -6,7 +6,7 @@ import { Select } from "../ui/Select";
import { Switch } from "../ui/Switch";
import { ChannelHeader, EmptyLine, Field, UrlListEditor } from "./controls";
import { HEADER_NAME_SUGGESTIONS, nextHeaderRowId } from "./model";
import type { BarkForm, EmailForm, HeaderRow, WebhookForm } from "./model";
import type { BarkForm, EmailForm, HeaderRow, WebhookForm, WecomForm } from "./model";
const HEADER_LIST_ID = "vocat-webhook-header-names";
@@ -267,3 +267,53 @@ export function WebhookTab({ value, onChange, testing, onTest }: PushChannelProp
</div>
);
}
export function WecomTab({ value, onChange, testing, onTest }: PushChannelProps<WecomForm>) {
const { t, lang } = useI18n();
const off = !value.enabled;
const complete = hasAnyUrl(value.urls) && !!value.payloadTemplate.trim();
return (
<div className="pt-2">
<ChannelHeader
title={t("启用企业微信消息推送")}
enabled={value.enabled}
onToggle={(enabled) => onChange({ enabled })}
actions={
<Button size="small" variant="primary" plain loading={testing} disabled={off || !complete} onClick={onTest}>
{t("测试通知")}
</Button>
}
/>
<SMSOnlyHint />
<div className="space-y-4">
<div className="rounded-lg bg-gray-50 px-3 py-2 text-xs leading-5 text-gray-500 dark:bg-gray-800/60 dark:text-gray-400">
{t("每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。")}
</div>
<UrlListEditor
urls={value.urls}
onChange={(urls) => onChange({ urls })}
enabled={value.enabled}
placeholder="https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
emptyText={t("尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。")}
/>
<Field
label={t("JSON 请求体模板")}
hint={
<>
{t("支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如")} <code>{"{{message}}"}</code>{lang === "zh" ? "。" : "."}
{t("可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。")}
</>
}
>
<Textarea
value={value.payloadTemplate}
onChange={(event) => onChange({ payloadTemplate: event.target.value })}
disabled={off}
rows={12}
className="font-mono text-xs"
/>
</Field>
</div>
</div>
);
}
+47 -17
View File
@@ -47,18 +47,32 @@ export interface EmailForm {
}
export interface PushplusForm {
enabled: boolean;
token: string;
topic: string;
channel: string;
enabled: boolean;
token: string;
topic: string;
channel: string;
}
export interface WecomForm {
enabled: boolean;
urls: string[];
payloadTemplate: string;
}
export const DEFAULT_WECOM_PAYLOAD_TEMPLATE = `{
"msgtype": "text",
"text": {
"content": {{message}}
}
}`;
export interface NotifyForms {
telegram: TelegramForm;
webhook: WebhookForm;
bark: BarkForm;
email: EmailForm;
pushplus: PushplusForm;
email: EmailForm;
pushplus: PushplusForm;
wecom: WecomForm;
}
// 系统保留头,自定义同名头会被忽略(品牌 vocat)
@@ -131,8 +145,9 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
const telegram = asRecord(data.telegram);
const webhook = asRecord(data.webhook);
const bark = asRecord(data.bark);
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const email = asRecord(data.email);
const pushplus = asRecord(data.pushplus);
const wecom = asRecord(data.wecom);
return {
telegram: {
enabled: !!telegram.enabled,
@@ -171,13 +186,18 @@ export function formsFromNotifications(data: Partial<NotificationSettings>): Not
fromAddress: str(email.fromAddress),
toAddresses: joinList(email.toAddresses),
},
pushplus: {
enabled: !!pushplus.enabled,
token: str(pushplus.token),
topic: str(pushplus.topic),
channel: str(pushplus.channel) || "wechat",
},
};
pushplus: {
enabled: !!pushplus.enabled,
token: str(pushplus.token),
topic: str(pushplus.topic),
channel: str(pushplus.channel) || "wechat",
},
wecom: {
enabled: !!wecom.enabled,
urls: strList(wecom.urls),
payloadTemplate: str(wecom.payloadTemplate ?? wecom.payload_template) || DEFAULT_WECOM_PAYLOAD_TEMPLATE,
},
};
}
function splitList(value: string): string[] {
@@ -226,6 +246,15 @@ export function buildEmailPayload(form: EmailForm, forTest = false) {
};
}
export function buildWecomPayload(form: WecomForm, forTest = false) {
const urls = Array.isArray(form.urls) ? form.urls : [];
return {
enabled: !!form.enabled,
urls: forTest ? urls.map((url) => String(url || "").trim()).filter(Boolean) : urls,
payload_template: String(form.payloadTemplate || ""),
};
}
export function buildNotificationsPayload(forms: NotifyForms) {
return {
telegram: {
@@ -245,6 +274,7 @@ export function buildNotificationsPayload(forms: NotifyForms) {
channel: forms.pushplus.channel || "",
},
webhook: buildWebhookPayload(forms.webhook),
bark: buildBarkPayload(forms.bark),
};
bark: buildBarkPayload(forms.bark),
wecom: buildWecomPayload(forms.wecom),
};
}
+1
View File
@@ -6,6 +6,7 @@ export const DEVICE_TYPES: ReadonlyArray<{ value: DeviceType; label: string; ima
{ value: "wifi_410", label: "410 WiFi 棒(高通芯片)", image: "/410.png" },
{ value: "dji_4g", label: "大疆 4G 模块(移远芯片)", image: "/dj.png" },
{ value: "pcie_ec20_ec25", label: "PCIe EC20/EC25(移远芯片)", image: "/ec20.png" },
{ value: "usb_sim_reader", label: "USB SIM 读卡器(仅 WiFi Calling", image: "/sim-reader.svg" },
];
export function normalizeDeviceType(value?: string | null): DeviceType {
+18 -1
View File
@@ -5,6 +5,10 @@
* /
*/
export const EN_DICT: Record<string, string> = {
"USB SIM 读卡器(仅 WiFi Calling": "USB SIM Reader (WiFi Calling only)",
"仅在 SIM 启用 PIN 时填写": "Only enter this when SIM PIN is enabled",
"留空表示不修改;仅在 SIM 启用 PIN 时填写": "Leave blank to keep unchanged; only enter this when SIM PIN is enabled",
"USB SIM 读卡器仅用于 WiFi Calling,策略跟随 ICCID 保存": "The USB SIM reader is for WiFi Calling only; policy is saved per ICCID",
// Cellular APN profiles.
"蜂窝 APN": "Cellular APN",
"APN 列表和启用状态跟随当前 ICCID/Profile 保存": "APN profiles and the active selection are saved per ICCID/Profile",
@@ -275,6 +279,7 @@ export const EN_DICT: Record<string, string> = {
"Webhook 测试失败": "Webhook test failed",
"Bark 测试失败": "Bark test failed",
"Email 测试失败": "Email test failed",
"企业微信消息推送测试失败": "WeCom message push test failed",
// ---- 设置页:安全卡 ----
: "Security",
@@ -372,10 +377,20 @@ export const EN_DICT: Record<string, string> = {
"启用 Bark 推送": "Enable Bark",
"启用 Email 推送": "Enable Email",
"启用 Webhook 推送": "Enable Webhook",
"企业微信消息推送": "WeCom Message Push",
"启用企业微信消息推送": "Enable WeCom Message Push",
"Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送": "Telegram / Bark / Email / Pushplus / Webhook / WeCom Message Push",
"目标 URLs": "Target URLs",
"添加 URL": "Add URL",
"尚未配置任何 Bark URL,点击右侧添加按钮。": "No Bark URLs yet. Click the add button on the right.",
"尚未配置任何 Webhook URL,点击右侧添加按钮。": "No Webhook URLs yet. Click the add button on the right.",
"每个企业微信消息推送 Webhook URL 单独占一行,点击添加 URL 新增一行;不使用逗号、空格或换行分隔多个 URL。":
"Enter one WeCom message push Webhook URL per line. Use Add URL to add another row; do not separate URLs with commas, spaces, or line breaks.",
"尚未配置任何企业微信消息推送 Webhook URL,点击右侧添加按钮。": "No WeCom message push Webhook URLs yet. Click the add button on the right.",
"JSON 请求体模板": "JSON Request Body Template",
"支持完整企业微信消息推送 JSON。变量必须作为 JSON 值使用,例如": "Supports a complete WeCom message push JSON payload. Use variables as JSON values, for example",
"可用变量:{{event}}、{{title}}、{{message}}、{{timestamp}}、{{content}}、{{number}}、{{device_id}}、{{device_name}}、{{device_label}}、{{time}}。":
"Available variables: {{event}}, {{title}}, {{message}}, {{timestamp}}, {{content}}, {{number}}, {{device_id}}, {{device_name}}, {{device_label}}, {{time}}.",
"分组 (Group)": "Group",
"例如 vocat": "e.g. vocat",
"iOS 设备上的通知分组。": "Notification group on iOS devices.",
@@ -754,7 +769,9 @@ export const EN_DICT: Record<string, string> = {
"暂无设备": "No devices",
"最后原因": "Last Reason",
"最后成功:": "Last success: ",
"未检测到 eUICC": "No eUICC detected",
"未检测到 eUICC": "No eUICC detected",
"SIM卡未插入": "No SIM card inserted",
"USB SIM读卡器仅支持VoWiFi短信和通话任务": "USB SIM readers support VoWiFi SMS and call tasks only",
"未生效": "Not in effect",
"未知": "Unknown",
"未知日期": "Unknown date",
+26 -4
View File
@@ -251,7 +251,7 @@ export default function AutomaticTasksPage() {
function edit(task?: AutomaticTask) {
const deviceId = task?.deviceId || devices[0]?.id || "";
const next = task ? {
let next = task ? {
id: task.id,
name: task.name,
enabled: task.enabled,
@@ -269,13 +269,21 @@ export default function AutomaticTasksPage() {
message: task.payload?.message || "",
durationSeconds: task.payload?.durationSeconds || 30,
} : emptyForm(deviceId);
if (devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader") {
next = { ...next, taskType: next.taskType === "public_ip" ? "sms" : next.taskType, environment: "vowifi" };
}
setForm(next);
setOpen(true);
void loadProfiles(deviceId, next.profileIccid);
}
function chooseDevice(deviceId: string) {
setForm((current) => ({ ...current, deviceId, profileIccid: "", profileAid: "" }));
const reader = devices.find((device) => device.id === deviceId)?.deviceType === "usb_sim_reader";
setForm((current) => ({
...current, deviceId, profileIccid: "", profileAid: "",
taskType: reader && current.taskType === "public_ip" ? "sms" : current.taskType,
environment: reader ? "vowifi" : current.environment,
}));
void loadProfiles(deviceId);
}
@@ -285,6 +293,7 @@ export default function AutomaticTasksPage() {
}
function chooseTaskType(taskType: TaskType) {
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && taskType === "public_ip") return;
setForm((current) => ({
...current,
taskType,
@@ -296,6 +305,9 @@ export default function AutomaticTasksPage() {
if (!form.name.trim()) return message.warning(t("请输入任务名称"));
if (!form.deviceId) return message.warning(t("请选择设备"));
if (!form.profileIccid) return message.warning(t("请选择 eSIM Profile"));
if (deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader" && (form.environment !== "vowifi" || form.taskType === "public_ip")) {
return message.warning(t("USB SIM读卡器仅支持VoWiFi短信和通话任务"));
}
if (form.taskType !== "public_ip" && !form.phone.trim()) return message.warning(t("请输入号码"));
if (form.taskType === "sms" && !form.message.trim()) return message.warning(t("请输入短信内容"));
setSaving(true);
@@ -377,6 +389,15 @@ export default function AutomaticTasksPage() {
const taskTypeLabel = (value: TaskType) => ({ sms: t("发送短信"), call: t("拨打电话并自动挂断"), public_ip: t("获取漫游公网 IP") })[value];
const environmentLabel = (value: TaskEnvironment) => value === "vowifi" ? "VoWiFi" : t("基站直连");
const selectedTaskDeviceIsReader = deviceByID.get(form.deviceId)?.deviceType === "usb_sim_reader";
const taskTypeOptions = [
{ value: "sms", label: t("发送短信") },
{ value: "call", label: t("拨打电话并自动挂断") },
...(!selectedTaskDeviceIsReader ? [{ value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }] : []),
];
const environmentOptions = selectedTaskDeviceIsReader
? [{ value: "vowifi", label: "VoWiFi" }]
: [{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }];
return (
<div className="mx-auto max-w-7xl">
@@ -473,8 +494,9 @@ export default function AutomaticTasksPage() {
<div className="md:col-span-2"><label className={fieldLabel}>{t("任务名称")}</label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder={t("例如:每日短信保活")} /></div>
<div><label className={fieldLabel}>{t("设备")}</label><Select value={form.deviceId} onChange={chooseDevice} options={devices.map((device) => ({ value: device.id, label: `${device.name || device.id} (${device.id})` }))} /></div>
<div><label className={fieldLabel}>{t("eSIM Profile")}</label><Select value={form.profileIccid} onChange={chooseProfile} disabled={profileLoading || !form.deviceId} placeholder={profileLoading ? t("读取 Profile 中...") : t("请选择 Profile")} options={profiles.map((profile) => ({ value: profile.iccid, label: profile.label }))} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={[{ value: "sms", label: t("发送短信") }, { value: "call", label: t("拨打电话并自动挂断") }, { value: "public_ip", label: t("开启漫游流量并获取一次公网 IP") }]} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip"} options={[{ value: "vowifi", label: "VoWiFi" }, { value: "cellular", label: t("基站直连(自动选网)") }]} /></div>
<div><label className={fieldLabel}>{t("任务类型")}</label><Select value={form.taskType} onChange={(value) => chooseTaskType(value as TaskType)} options={taskTypeOptions} /></div>
<div><label className={fieldLabel}>{t("执行环境")}</label><Select value={form.environment} onChange={(value) => setForm({ ...form, environment: value as TaskEnvironment })} disabled={form.taskType === "public_ip" || selectedTaskDeviceIsReader} options={environmentOptions} /></div>
{selectedTaskDeviceIsReader ? <div className="md:col-span-2 rounded-lg border border-sky-200 bg-sky-50 p-3 text-sm text-sky-700 dark:border-sky-500/20 dark:bg-sky-500/10 dark:text-sky-300">{t("USB SIM读卡器仅支持VoWiFi短信和通话任务")}</div> : null}
{form.taskType !== "public_ip" ? <div><label className={fieldLabel}>{t("号码")}</label><Input value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} placeholder="+447700900123" /></div> : null}
{form.taskType === "call" ? <div><label className={fieldLabel}>{t("自动挂断")}</label><Input type="number" min={1} max={600} value={form.durationSeconds} suffix="s" onChange={(event) => setForm({ ...form, durationSeconds: Number(event.target.value) })} /></div> : null}
+12 -3
View File
@@ -32,6 +32,7 @@ const EMPTY_ADD: AddDeviceForm = {
atPort: "",
controlDevice: "",
deviceBackend: "at",
simPin: "",
};
export default function DevicesPage() {
@@ -373,7 +374,8 @@ export default function DevicesPage() {
setAddSelected(d);
setAddConfig((prev) => {
const mode = String(d.mode || "").toLowerCase();
const backend = mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
const isReader = d.hardwareKind === "pcsc" || mode === "pcsc";
const backend = isReader ? "pcsc" : mode === "mbim" ? "mbim" : isQmiControl(d.controlPath) || (mode === "qmi" && d.controlPath) ? "qmi" : "at";
return {
...prev,
interface: d.netInterface || "",
@@ -382,6 +384,8 @@ export default function DevicesPage() {
modemImei: d.imei || "",
usbPath: d.usbPath || "",
deviceBackend: backend,
deviceType: isReader ? "usb_sim_reader" : prev.deviceType,
esimTransport: isReader ? "pcsc" : backend,
};
});
}, []);
@@ -574,6 +578,10 @@ export default function DevicesPage() {
const unconfiguredDiscovered = useMemo(() => discovered.filter((d) => !d.configured), [discovered]);
const detailOnline = isDeviceOnline(detail);
const isReader = detail?.deviceType === "usb_sim_reader";
useEffect(() => {
if (isReader && ["at", "ussd"].includes(activeTab)) setActiveTab("overview");
}, [isReader, activeTab]);
const addAtLimit = deviceLimit > 0 && list.length >= deviceLimit;
const tabItems = [
{ key: "overview", label: t("概览") },
@@ -582,7 +590,7 @@ export default function DevicesPage() {
{ key: "ussd", label: t("USSD") },
{ key: "config", label: t("配置") },
{ key: "card", label: t("卡策略") },
];
].filter((tab) => !isReader || !["at", "ussd"].includes(tab.key));
const overviewNode = detail ? (
<div className="space-y-4">
@@ -673,6 +681,7 @@ export default function DevicesPage() {
onReconnectVowifi={handleReconnectVoWiFi}
onRebootModem={handleRebootModem}
onOpenSms={handleOpenSms}
wifiCallingOnly={isReader}
/>
<div className="device-detail-tabs ui-card p-6">
<Tabs tabs={tabItems} value={activeTab} onChange={handleTabChange} />
@@ -697,7 +706,7 @@ export default function DevicesPage() {
<DeviceConfigTab editConfig={editConfig} deviceStatus={detail} saving={saving} deleting={deleting} onSave={handleSaveConfig} onDelete={handleDeleteDevice} onEditConfig={setEditConfig} />
) : null}
{activeTab === "card" ? (
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} />
<CardPolicyPanel deviceId={detail.id} iccid={detail.modem?.iccid} policy={cardPolicy} deviceOnline={detailOnline} onPolicyChanged={handlePolicyChanged} wifiCallingOnly={isReader} />
) : null}
</div>
</div>
+23 -2
View File
@@ -14,13 +14,14 @@ import {
buildBarkPayload,
buildEmailPayload,
buildNotificationsPayload,
buildWecomPayload,
buildWebhookPayload,
defaultNotifyForms,
formsFromNotifications,
type NotifyForms,
} from "../components/settings/model";
import { PushplusTab, TelegramTab } from "../components/settings/BotTabs";
import { BarkTab, EmailTab, WebhookTab } from "../components/settings/PushTabs";
import { BarkTab, EmailTab, WebhookTab, WecomTab } from "../components/settings/PushTabs";
import { PluginsCard } from "../components/settings/PluginsCard";
import { HTTPSCard } from "../components/settings/HTTPSCard";
import { DeviceQuotaCard } from "../components/settings/DeviceQuotaCard";
@@ -34,6 +35,7 @@ const NOTIFY_TABS = [
{ key: "email", label: "Email" },
{ key: "pushplus", label: "Pushplus" },
{ key: "webhook", label: "Webhook" },
{ key: "wecom", label: "企业微信消息推送" },
];
const EMPTY_SYSTEM_INFO: SystemInfo = { version: "", buildTime: "", config: "" };
@@ -51,6 +53,7 @@ export default function SettingsPage() {
const [testingWebhook, setTestingWebhook] = useState(false);
const [testingBark, setTestingBark] = useState(false);
const [testingEmail, setTestingEmail] = useState(false);
const [testingWecom, setTestingWecom] = useState(false);
const [changingPassword, setChangingPassword] = useState(false);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [applyingUpdate, setApplyingUpdate] = useState(false);
@@ -320,6 +323,21 @@ export default function SettingsPage() {
}
}, [forms.email]);
const onTestWecom = useCallback(async () => {
setTestingWecom(true);
try {
await api("/settings/notifications/wecom/test", {
method: "POST",
body: buildWecomPayload(forms.wecom, true),
});
message.success(t("测试通知已发送"));
} catch (error) {
message.error(apiMessage(error) || t("企业微信消息推送测试失败"));
} finally {
setTestingWecom(false);
}
}, [forms.wecom]);
const onCheckUpdate = useCallback(async () => {
setCheckingUpdate(true);
try {
@@ -448,7 +466,7 @@ export default function SettingsPage() {
<CardIcon>
<AlertRegular className="text-[24px]" />
</CardIcon>
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook")} />
<CardTitle title={t("通知")} subtitle={t("Telegram / Bark / Email / Pushplus / Webhook / 企业微信消息推送")} />
</div>
<Button variant="primary" loading={savingNotif} disabled={loadingNotif} onClick={onSaveNotifications} className="!border-0" icon={<CheckmarkRegular />}>
{t("保存通知配置")}
@@ -479,6 +497,9 @@ export default function SettingsPage() {
onTest={onTestWebhook}
/>
) : null}
{activeTab === "wecom" ? (
<WecomTab value={forms.wecom} onChange={(p) => updateChannel("wecom", p)} testing={testingWecom} onTest={onTestWecom} />
) : null}
</div>
)}
</div>
+7 -3
View File
@@ -1,6 +1,6 @@
export type ApiStatus = "ok" | "error";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25";
export type DeviceType = "wifi_410" | "dji_4g" | "pcie_ec20_ec25" | "usb_sim_reader";
export interface Session {
authenticated: boolean;
@@ -167,6 +167,8 @@ export interface DeviceStatus {
}
export interface DiscoveredDevice {
hardwareKind?: string;
readerName?: string;
discoveryKey: string;
controlPath: string;
netInterface: string;
@@ -196,14 +198,15 @@ export interface DeviceConfig {
usbPath: string;
audioDevice?: string;
modemImei?: string;
simPin?: string;
apn: string;
proxyPort: number;
baudRate: number;
dataBits: number;
stopBits: number;
parity: string;
deviceBackend: "at" | "qmi";
esimTransport: "at" | "qmi";
deviceBackend: "at" | "qmi" | "pcsc";
esimTransport: "at" | "qmi" | "pcsc" | "none";
qmiUseProxy: boolean;
qmiProxyPath?: string;
qmiProxyExecutable?: string;
@@ -372,6 +375,7 @@ export interface NotificationSettings {
bark: Record<string, unknown>;
email: Record<string, unknown>;
pushplus: Record<string, unknown>;
wecom: Record<string, unknown>;
}
// 网络访问控制策略:默认仅放行内网网段,可切换到对公网开放。