Initial commit: Web UI improvements, ARM64 support, and documentation

This commit is contained in:
HoshinoDesu committed 2026-02-11 23:23:56 +08:00
commit 930e5ba8d1
25 files changed
+22747

No files matched your search

+87
View File
@@ -0,0 +1,87 @@
# SMS Forwarder - AI编程助手指南
## 项目概览
SMS Forwarder 是一个用 Go 编写的短信转发工具,从 ModemManager (Linux D-Bus) 接收 SMS,支持存储到 JSON 并转发到多个通知渠道。
**关键依赖**: `godbus/dbus/v5` (D-Bus 交互), `BurntSushi/toml` (配置解析)
## 架构组件
```
main.go → forwarder → [modem/manager + storage + notifier]
↓ ↓
D-Bus/ModemManager 多通道发送 (email/bark/gotify/serverchan/webhook)
```
### 数据流向
1. **Modem Manager** ([modem/manager.go](internal/modem/manager.go)) 通过 D-Bus 连接 ModemManager,订阅设备添加/移除事件
2. **Messaging** ([modem/messaging.go](internal/modem/messaging.go)) 为每个调制解调器创建独立 D-Bus 连接,订阅 SMS "Added" 信号
3. **Forwarder** ([forwarder/forwarder.go](internal/forwarder/forwarder.go)) 协调所有组件:
- 维护设备 `ObjectPath` 到 IMEI 的映射 (`equipment`, `modems` maps)
- 处理设备插拔 (hotplug),通过 `cancels` map 管理 goroutine 生命周期
- 去重设备:同一 IMEI 只保留一个活动订阅
4. **Storage** ([storage/storage.go](internal/storage/storage.go)) 使用互斥锁保护 JSON 文件的读写操作
5. **Notifier** ([notifier/notifier.go](internal/notifier/notifier.go)) 遍历所有通道发送通知,记录失败但继续尝试后续通道
## D-Bus 交互模式
**关键点**: ModemManager 使用 D-Bus Object Manager 接口
- **系统总线连接**: 所有操作都在 `dbus.SystemBus()` 上进行
- **独立订阅连接**: [messaging.go#L39-L52](internal/modem/messaging.go#L39-L52) 为每个调制解调器的 SMS 订阅创建独立的 `dbus.SystemBusPrivate()` 连接,确保信号隔离
- **信号匹配**: 使用 `WithMatchPathNamespace` 限制信号范围,避免接收无关事件
- **属性读取**: `GetProperty()` 获取 `EquipmentIdentifier`, `Model` 等属性
## 配置模式
[config.example.toml](config.example.toml) 展示了配置结构:
- **单一配置文件**: TOML 格式,支持 `[[channels]]` 数组定义多个通知渠道
- **通道类型**: 所有通道共用 `ChannelConfig` 结构体,通过 `type` 字段区分 (email/bark/gotify/serverchan/webhook)
- **可选存储**: `storage.enabled` 控制是否保存到 JSON 文件
- **调制解调器别名**: 可选的 `[[modems]]` 数组通过 IMEI 设置设备别名
## 并发与生命周期管理
- **Context 驱动**: [main.go#L49-L57](main.go#L49-L57) 使用 `context.WithCancel` 管理整个应用生命周期
- **每设备 Context**: [forwarder.go#L110-L113](internal/forwarder/forwarder.go#L110-L113) 为每个调制解调器创建独立的子 context
- **设备热插拔**: `addModem` 检测重复设备时先 `cancel()` 旧订阅再创建新订阅
- **优雅关闭**: [main.go#L62-L68](main.go#L62-L68) 捕获 SIGINT/SIGTERM 信号后取消 context,触发所有 goroutine 退出
## 常见模式
### 添加新通知渠道
1. 在 [notifier/](internal/notifier/) 创建 `<type>.go` (参考 [bark.go](internal/notifier/bark.go) 的简单实现)
2. 实现 `Channel` 接口: `Send(Message) error` 和 `Type() string`
3. 在 [notifier.go#L88](internal/notifier/notifier.go#L88) 的 `createChannel()` switch 中添加 case
4. 在 [config.go](internal/config/config.go) 的 `ChannelConfig` 添加所需配置字段
5. 更新 [config.example.toml](config.example.toml) 添加示例配置
### D-Bus 调试
- **查看可用调制解调器**: `mmcli -L`
- **查看短信**: `mmcli -m <modem-id> --messaging-list-sms`
- **监控 D-Bus 信号**: `dbus-monitor --system "type='signal',interface='org.freedesktop.DBus.ObjectManager'"`
## 运行与构建
```bash
# 开发运行
go run main.go -config config.toml
# 构建
go build -o sms-forwarder
# 测试 (需要 ModemManager)
go test ./...
```
## 特殊注意
- **仅限 Linux**: 依赖 ModemManager D-Bus 接口,不支持 Windows/macOS
- **设备去重**: [forwarder.go#L95-L104](internal/forwarder/forwarder.go#L95-L104) 使用 `EquipmentIdentifier` (IMEI) 作为唯一标识,防止同一设备重复订阅
- **异步通知**: 所有通知通道并行发送,一个失败不影响其他通道
- **SMS 接收延迟**: [messaging.go#L85](internal/modem/messaging.go#L85) 调用 `waitForSMSReceived` 等待 SMS 状态变为已接收 (D-Bus 信号和属性更新有时序差异)
+25
View File
@@ -0,0 +1,25 @@
# 二进制文件
sms-forwarder
*.exe
# 配置文件
config.toml
# 数据文件
sms.json
*.db
# 日志
*.log
# Go
*.test
*.out
vendor/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
+45
View File
@@ -0,0 +1,45 @@
# SMS Forwarder
一个简单的短信转发工具,基于 ModemManager 开发,支持将短信转发到多种通知渠道。
## 功能特性
- 支持多种转发接通道:Email, Bark, Gotify, ServerChan, Webhook。
- 提供 Web 管理界面,支持查看短信列表和设备状态。
- 支持 USSD 代码执行(如查询话费)。
- 支持短信去重和持久化存储。
- 适配 Linux x64 和 Linux ARM64 平台 (仅支持 Linux 系统)。
## 已测试设备
- Sierra Wireless AirPrime® EM7430
- Qualcomm® Snapdragon™ 410 UFI
## 编译方法
使用 Go 语言进行编译:
```bash
# 编译 Linux ARM64 版本
GOOS=linux GOARCH=arm64 go build -o sms-forwarder-linux-arm64
```
## 配置说明
1. 将 `config.example.toml` 重命名为 `config.toml`。
2. 编辑 `config.toml` 文件,配置短信转发通道和相关参数。
## 运行
直接运行编译后的二进制文件:
```bash
./sms-forwarder-linux-arm64
```
程序默认监听端口可在配置文件中修改。
## 注意事项
- 程序依赖 ModemManager,请确保运行环境已安装并运行 ModemManager 服务。
- 请确保有足够的权限访问 DBus 系统总线。
+57
View File
@@ -0,0 +1,57 @@
# SMS 转发器配置文件
# 存储配置
[storage]
enabled = true
path = "./sms.json" # 短信保存路径
# Web 服务器配置
[server]
enabled = true
listen = ":8080" # 监听地址,访问 http://localhost:8080
# 通知通道配置
# 支持多个通道,按顺序尝试发送
# Email 通道
[[channels]]
type = "email"
enabled = true # 是否启用此通道
host = "smtp.example.com"
port = 587
username = "your-email@example.com"
password = "your-password"
from = "your-email@example.com"
to = ["recipient@example.com"]
# use_tls = true # 可选,默认 true
# Bark 通道 (iOS)
[[channels]]
type = "bark"
enabled = false # 是否启用此通道
endpoint = "https://api.day.app/your-device-key"
# title = "SMS 通知" # 可选
# Gotify 通道
[[channels]]
type = "gotify"
enabled = false # 是否启用此通道
endpoint = "https://gotify.example.com"
token = "your-app-token"
# priority = 5 # 可选,默认 5
# ServerChan (Server酱) 通道
[[channels]]
type = "serverchan"
enabled = false # 是否启用此通道
send_key = "your-sendkey"
# Webhook 通道
[[channels]]
type = "webhook"
enabled = false # 是否启用此通道
url = "https://your-webhook-endpoint.com/notify"
method = "POST" # GET 或 POST
# headers 可选
[channels.headers]
Authorization = "Bearer your-token"
+13
View File
@@ -0,0 +1,13 @@
module github.com/sms-forwarder
go 1.21.6
require (
github.com/BurntSushi/toml v1.3.2
github.com/godbus/dbus/v5 v5.1.0
)
require (
github.com/easychen/serverchan-sdk-golang v1.0.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
)
+8
View File
@@ -0,0 +1,8 @@
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/easychen/serverchan-sdk-golang v1.0.0 h1:4B0v0e9+OAFILgCarTMdLLAMekacnINLSZMCKLmHrwk=
github.com/easychen/serverchan-sdk-golang v1.0.0/go.mod h1:8zrp/XzKEQgi+KhiVGkeI+WBmdIDOlFMjBK+3pWIVpo=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+180
View File
@@ -0,0 +1,180 @@
package config
import (
"fmt"
"os"
"github.com/BurntSushi/toml"
)
// Config 主配置结构
type Config struct {
Storage StorageConfig `toml:"storage"`
Server ServerConfig `toml:"server"`
Channels []ChannelConfig `toml:"channels"`
}
// StorageConfig 存储配置
type StorageConfig struct {
Enabled bool `toml:"enabled"`
Path string `toml:"path"`
}
// ServerConfig 服务器配置
type ServerConfig struct {
Enabled bool `toml:"enabled"` // 是否启用 Web 服务器
Listen string `toml:"listen"` // 监听地址,如 ":8080"
}
// ChannelConfig 通道配置
type ChannelConfig struct {
Type string `toml:"type" json:"type"` // email, bark, gotify, serverchan, pushdeer, webhook
Enabled bool `toml:"enabled" json:"enabled"` // 是否启用,默认 false
// Email
Host string `toml:"host" json:"host"`
Port int `toml:"port" json:"port"`
Username string `toml:"username" json:"username"`
Password string `toml:"password" json:"password"`
From string `toml:"from" json:"from"`
To []string `toml:"to" json:"to"`
UseTLS bool `toml:"use_tls" json:"use_tls"`
// Bark
Endpoint string `toml:"endpoint" json:"endpoint"`
Title string `toml:"title" json:"title"`
// Gotify
Token string `toml:"token" json:"token"`
Priority int `toml:"priority" json:"priority"`
// ServerChan
SendKey string `toml:"send_key" json:"send_key"`
// Webhook
URL string `toml:"url" json:"url"`
Method string `toml:"method" json:"method"`
Headers map[string]string `toml:"headers" json:"headers"`
}
// Load 加载配置文件
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("读取配置文件: %w", err)
}
var cfg Config
if err := toml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("解析配置文件: %w", err)
}
return &cfg, nil
}
// Save 保存配置到文件
func (c *Config) Save(path string) error {
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建配置文件: %w", err)
}
defer f.Close()
// 构建用于保存的配置,过滤掉每个通道不相关的字段
saveConfig := struct {
Storage StorageConfig `toml:"storage"`
Server ServerConfig `toml:"server"`
Channels []any `toml:"channels"`
}{
Storage: c.Storage,
Server: c.Server,
Channels: make([]any, 0, len(c.Channels)),
}
for _, ch := range c.Channels {
switch ch.Type {
case "email":
saveConfig.Channels = append(saveConfig.Channels, struct {
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
Host string `toml:"host"`
Port int `toml:"port"`
Username string `toml:"username"`
Password string `toml:"password"`
From string `toml:"from"`
To []string `toml:"to"`
UseTLS bool `toml:"use_tls,omitempty"`
}{
Type: ch.Type,
Enabled: ch.Enabled,
Host: ch.Host,
Port: ch.Port,
Username: ch.Username,
Password: ch.Password,
From: ch.From,
To: ch.To,
UseTLS: ch.UseTLS,
})
case "bark":
saveConfig.Channels = append(saveConfig.Channels, struct {
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
Endpoint string `toml:"endpoint"`
Title string `toml:"title,omitempty"`
}{
Type: ch.Type,
Enabled: ch.Enabled,
Endpoint: ch.Endpoint,
Title: ch.Title,
})
case "gotify":
saveConfig.Channels = append(saveConfig.Channels, struct {
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
Endpoint string `toml:"endpoint"`
Token string `toml:"token"`
Priority int `toml:"priority,omitempty"`
}{
Type: ch.Type,
Enabled: ch.Enabled,
Endpoint: ch.Endpoint,
Token: ch.Token,
Priority: ch.Priority,
})
case "serverchan":
saveConfig.Channels = append(saveConfig.Channels, struct {
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
SendKey string `toml:"send_key"`
}{
Type: ch.Type,
Enabled: ch.Enabled,
SendKey: ch.SendKey,
})
case "webhook":
saveConfig.Channels = append(saveConfig.Channels, struct {
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
Method string `toml:"method"`
Headers map[string]string `toml:"headers,omitempty"`
}{
Type: ch.Type,
Enabled: ch.Enabled,
URL: ch.URL,
Method: ch.Method,
Headers: ch.Headers,
})
default:
// 未知类型,保存所有字段
saveConfig.Channels = append(saveConfig.Channels, ch)
}
}
encoder := toml.NewEncoder(f)
if err := encoder.Encode(saveConfig); err != nil {
return fmt.Errorf("编码配置文件: %w", err)
}
return nil
}
+305
View File
@@ -0,0 +1,305 @@
package forwarder
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"sync"
"time"
"github.com/godbus/dbus/v5"
"github.com/sms-forwarder/internal/config"
"github.com/sms-forwarder/internal/modem"
"github.com/sms-forwarder/internal/notifier"
"github.com/sms-forwarder/internal/storage"
)
// Forwarder 短信转发器
type Forwarder struct {
cfg *config.Config
manager *modem.Manager
storage *storage.Storage
notifier *notifier.Notifier
mu sync.Mutex
cancels map[dbus.ObjectPath]context.CancelFunc
equipment map[string]dbus.ObjectPath
modems map[dbus.ObjectPath]string
processed map[string]time.Time // key: timestamp_from_text
}
// New 创建转发器
func New(cfg *config.Config, manager *modem.Manager, store *storage.Storage) (*Forwarder, error) {
notif, err := notifier.New(cfg)
if err != nil {
return nil, fmt.Errorf("创建通知器: %w", err)
}
return &Forwarder{
cfg: cfg,
manager: manager,
storage: store,
notifier: notif,
cancels: make(map[dbus.ObjectPath]context.CancelFunc),
equipment: make(map[string]dbus.ObjectPath),
modems: make(map[dbus.ObjectPath]string),
processed: make(map[string]time.Time),
}, nil
}
// Run 运行转发器
func (f *Forwarder) Run(ctx context.Context) error {
if len(f.cfg.Channels) == 0 && (f.storage == nil || !f.cfg.Storage.Enabled) {
slog.Info("未配置任何通道或存储,转发器将不执行任何操作")
<-ctx.Done()
return nil
}
// 获取现有调制解调器
modems, err := f.manager.Modems()
if err != nil {
return fmt.Errorf("获取调制解调器列表: %w", err)
}
for path, m := range modems {
f.addModem(ctx, path, m)
}
// 订阅调制解调器事件
unsubscribe, err := f.manager.Subscribe(func(event modem.ModemEvent) error {
switch event.Type {
case modem.ModemEventAdded:
if event.Modem == nil {
return nil
}
f.addModem(ctx, event.Path, event.Modem)
case modem.ModemEventRemoved:
f.removeModem(event.Path)
}
return nil
})
if err != nil {
return fmt.Errorf("订阅调制解调器事件: %w", err)
}
defer unsubscribe()
<-ctx.Done()
f.stopAll()
return nil
}
// addModem 添加调制解调器
func (f *Forwarder) addModem(ctx context.Context, path dbus.ObjectPath, m *modem.Modem) {
if ctx.Err() != nil {
return
}
f.mu.Lock()
// 处理重复设备
var oldCancel context.CancelFunc
if m.EquipmentIdentifier != "" {
if existingPath, ok := f.equipment[m.EquipmentIdentifier]; ok && existingPath != path {
oldCancel = f.cancels[existingPath]
delete(f.cancels, existingPath)
delete(f.modems, existingPath)
delete(f.equipment, m.EquipmentIdentifier)
slog.Info("检测到重复设备,取消旧订阅", "imei", m.EquipmentIdentifier, "old_path", existingPath, "new_path", path)
}
}
// 检查是否已订阅
if _, ok := f.cancels[path]; ok {
f.mu.Unlock()
return
}
modemCtx, cancel := context.WithCancel(ctx)
f.cancels[path] = cancel
if m.EquipmentIdentifier != "" {
f.equipment[m.EquipmentIdentifier] = path
f.modems[path] = m.EquipmentIdentifier
}
f.mu.Unlock()
// 立即取消旧订阅
if oldCancel != nil {
oldCancel()
}
slog.Info("订阅调制解调器", "imei", m.EquipmentIdentifier, "model", m.Model)
// 启动订阅
go func() {
if err := m.Messaging().Subscribe(modemCtx, func(message *modem.SMS) error {
return f.handleMessage(m, message)
}); err != nil && !errors.Is(err, context.Canceled) {
slog.Error("调制解调器订阅停止", "error", err, "imei", m.EquipmentIdentifier)
}
f.removeModem(path)
}()
}
// removeModem 移除调制解调器
func (f *Forwarder) removeModem(path dbus.ObjectPath) {
var cancel context.CancelFunc
f.mu.Lock()
cancel = f.cancels[path]
delete(f.cancels, path)
if equipmentID, ok := f.modems[path]; ok {
delete(f.modems, path)
delete(f.equipment, equipmentID)
slog.Info("移除调制解调器", "imei", equipmentID)
}
f.mu.Unlock()
if cancel != nil {
cancel()
}
}
// stopAll 停止所有订阅
func (f *Forwarder) stopAll() {
f.mu.Lock()
cancels := make([]context.CancelFunc, 0, len(f.cancels))
for _, cancel := range f.cancels {
cancels = append(cancels, cancel)
}
f.cancels = make(map[dbus.ObjectPath]context.CancelFunc)
f.equipment = make(map[string]dbus.ObjectPath)
f.modems = make(map[dbus.ObjectPath]string)
f.mu.Unlock()
for _, cancel := range cancels {
cancel()
}
}
// handleMessage 处理接收到的短信
func (f *Forwarder) handleMessage(m *modem.Modem, sms *modem.SMS) error {
// 只处理接收的短信,跳过发送的短信
if sms.State == modem.SMSStateSent || sms.State == modem.SMSStateSending {
slog.Debug("跳过已发送的短信", "state", sms.State.String(), "from", sms.Number)
return nil
}
// incoming := sms.State == modem.SMSStateReceived || sms.State == modem.SMSStateReceiving
smsPath := sms.Path()
slog.Info("handleMessage调用", "path", smsPath, "from", sms.Number, "state", sms.State.String(), "text_preview", sms.Text[:min(20, len(sms.Text))])
// 优先过滤旧消息(避免重启后重复发送)
// if incoming && !sms.Timestamp.IsZero() && time.Since(sms.Timestamp) > 5*time.Minute {
// slog.Info("跳过 5 分钟前的短信", "timestamp", sms.Timestamp, "imei", m.EquipmentIdentifier)
// return nil
// }
// 检查存储中是否已存在(持久化去重)
if f.storage != nil && f.cfg.Storage.Enabled {
if f.storage.Has(m.EquipmentIdentifier, sms) {
slog.Debug("跳过已存储的重复短信", "timestamp", sms.Timestamp, "from", sms.Number)
return nil
}
}
if f.isDuplicateSMS(sms) {
slog.Debug("跳过重复短信", "path", sms.Path(), "from", sms.Number)
return nil
}
slog.Info("收到短信",
"imei", m.EquipmentIdentifier,
"from", sms.Number,
"timestamp", sms.Timestamp,
"text", sms.Text,
)
// 保存到存储
if f.storage != nil && f.cfg.Storage.Enabled {
if err := f.storage.Save(m.EquipmentIdentifier, sms); err != nil {
slog.Error("保存短信失败", "error", err)
}
}
// 转发到通知通道
if len(f.cfg.Channels) > 0 {
msg := f.formatMessage(m, sms)
if err := f.notifier.Send(msg); err != nil {
slog.Error("发送通知失败", "error", err)
return err
}
}
return nil
}
func (f *Forwarder) isDuplicateSMS(sms *modem.SMS) bool {
if sms == nil {
return false
}
// 使用 timestamp+from+text 作为唯一标识
key := fmt.Sprintf("%d_%s_%s", sms.Timestamp.Unix(), sms.Number, sms.Text)
now := time.Now()
cutoff := now.Add(-2 * time.Hour)
f.mu.Lock()
defer f.mu.Unlock()
// 清理过期记录
for k, t := range f.processed {
if t.Before(cutoff) {
delete(f.processed, k)
}
}
if lastTime, exists := f.processed[key]; exists {
slog.Info("检测到重复短信", "key", key, "last_processed", lastTime, "gap_ms", now.Sub(lastTime).Milliseconds())
return true
}
f.processed[key] = now
slog.Debug("短信首次处理", "key", key)
return false
}
// formatMessage 格式化消息
func (f *Forwarder) formatMessage(m *modem.Modem, sms *modem.SMS) notifier.Message {
incoming := sms.State == modem.SMSStateReceived || sms.State == modem.SMSStateReceiving
sender, recipient := sms.Number, m.Number
if !incoming {
sender, recipient = recipient, sender
}
modemName := f.getModemName(m)
return notifier.Message{
Modem: modemName,
From: sender,
To: recipient,
Timestamp: sms.Timestamp,
Text: strings.TrimSpace(sms.Text),
Incoming: incoming,
}
}
// getModemName 获取调制解调器名称
func (f *Forwarder) getModemName(m *modem.Modem) string {
if m.Model != "" {
return strings.TrimSpace(m.Model)
}
return m.EquipmentIdentifier
}
// GetModems 获取所有调制解调器信息
func (f *Forwarder) GetModems() []*modem.Modem {
f.mu.Lock()
defer f.mu.Unlock()
modems := make([]*modem.Modem, 0, len(f.modems))
for path := range f.modems {
if m, err := modem.NewModem(f.manager.Conn(), path); err == nil {
modems = append(modems, m)
}
}
return modems
}
+128
View File
@@ -0,0 +1,128 @@
package modem
import (
"fmt"
"github.com/godbus/dbus/v5"
)
const (
ModemManagerInterface = "org.freedesktop.ModemManager1"
ModemManagerPath = "/org/freedesktop/ModemManager1"
ObjectManagerInterface = "org.freedesktop.DBus.ObjectManager"
)
// Manager 调制解调器管理器
type Manager struct {
conn *dbus.Conn
dbusObject dbus.BusObject
}
// NewManager 创建管理器
func NewManager() (*Manager, error) {
conn, err := dbus.SystemBus()
if err != nil {
return nil, fmt.Errorf("连接系统总线: %w", err)
}
obj := conn.Object(ModemManagerInterface, ModemManagerPath)
return &Manager{
conn: conn,
dbusObject: obj,
}, nil
}
// Modems 获取所有调制解调器
func (m *Manager) Modems() (map[dbus.ObjectPath]*Modem, error) {
// 使用 ObjectManager 接口获取所有管理的对象
managedObjects := make(map[dbus.ObjectPath]map[string]map[string]dbus.Variant)
err := m.dbusObject.Call(ObjectManagerInterface+".GetManagedObjects", 0).Store(&managedObjects)
if err != nil {
return nil, fmt.Errorf("获取调制解调器列表: %w", err)
}
modems := make(map[dbus.ObjectPath]*Modem)
for objectPath, interfaces := range managedObjects {
// 检查对象是否包含 Modem 接口
if _, hasModem := interfaces[ModemInterface]; !hasModem {
continue
}
modem, err := NewModem(m.conn, objectPath)
if err != nil {
continue
}
modems[objectPath] = modem
}
return modems, nil
}
// Subscribe 订阅调制解调器事件
func (m *Manager) Subscribe(handler func(event ModemEvent) error) (func(), error) {
if err := m.conn.AddMatchSignal(
dbus.WithMatchInterface(ObjectManagerInterface),
dbus.WithMatchMember("InterfacesAdded"),
dbus.WithMatchPathNamespace(ModemManagerPath),
); err != nil {
return nil, err
}
if err := m.conn.AddMatchSignal(
dbus.WithMatchInterface(ObjectManagerInterface),
dbus.WithMatchMember("InterfacesRemoved"),
dbus.WithMatchPathNamespace(ModemManagerPath),
); err != nil {
return nil, err
}
signalChan := make(chan *dbus.Signal, 10)
m.conn.Signal(signalChan)
go func() {
for sig := range signalChan {
var event ModemEvent
event.Path = sig.Body[0].(dbus.ObjectPath)
switch sig.Name {
case ObjectManagerInterface + ".InterfacesAdded":
event.Type = ModemEventAdded
modem, err := NewModem(m.conn, event.Path)
if err == nil {
event.Modem = modem
}
case ObjectManagerInterface + ".InterfacesRemoved":
event.Type = ModemEventRemoved
}
_ = handler(event)
}
}()
unsubscribe := func() {
m.conn.RemoveSignal(signalChan)
close(signalChan)
}
return unsubscribe, nil
}
// Conn 返回 D-Bus 连接
func (m *Manager) Conn() *dbus.Conn {
return m.conn
}
// ModemEventType 调制解调器事件类型
type ModemEventType int
const (
ModemEventAdded ModemEventType = iota
ModemEventRemoved
)
// ModemEvent 调制解调器事件
type ModemEvent struct {
Type ModemEventType
Path dbus.ObjectPath
Modem *Modem
}
+137
View File
@@ -0,0 +1,137 @@
package modem
import (
"context"
"log/slog"
"time"
"github.com/godbus/dbus/v5"
)
const ModemMessagingInterface = ModemInterface + ".Messaging"
// Messaging 消息接口
type Messaging struct {
modem *Modem
}
// List 列出所有短信
func (msg *Messaging) List() ([]*SMS, error) {
var messages []dbus.ObjectPath
err := msg.modem.dbusObject.Call(ModemMessagingInterface+".List", 0).Store(&messages)
if err != nil {
return nil, err
}
s := make([]*SMS, 0, len(messages))
for _, message := range messages {
sms, err := msg.Retrieve(message)
if err != nil {
slog.Error("获取短信失败", "path", message, "error", err)
continue
}
s = append(s, sms)
}
return s, nil
}
// Subscribe 订阅新短信
func (msg *Messaging) Subscribe(ctx context.Context, subscriber func(message *SMS) error) error {
conn, err := dbus.SystemBusPrivate()
if err != nil {
return err
}
if err := conn.Auth(nil); err != nil {
conn.Close()
return err
}
if err := conn.Hello(); err != nil {
conn.Close()
return err
}
defer func() {
if err := conn.Close(); err != nil {
slog.Error("关闭 DBus 连接失败", "error", err)
}
}()
if err := conn.AddMatchSignal(
dbus.WithMatchMember("Added"),
dbus.WithMatchPathNamespace(msg.modem.objectPath),
); err != nil {
return err
}
signalChan := make(chan *dbus.Signal, 10)
conn.Signal(signalChan)
defer conn.RemoveSignal(signalChan)
for {
select {
case sig := <-signalChan:
if len(sig.Body) < 2 {
continue
}
// Body[0] 是 ObjectPath, Body[1] 是 bool (received)
received, ok := sig.Body[1].(bool)
if !ok || !received {
continue
}
path, ok := sig.Body[0].(dbus.ObjectPath)
if !ok {
continue
}
sms, err := msg.waitForSMSReceived(ctx, conn, path, 100*time.Millisecond)
if err != nil {
slog.Error("处理短信失败", "error", err, "path", path)
continue
}
if err := subscriber(sms); err != nil {
slog.Error("订阅者处理失败", "error", err, "path", path)
}
case <-ctx.Done():
slog.Info("取消订阅短信", "modem", msg.modem.EquipmentIdentifier)
return ctx.Err()
}
}
}
// waitForSMSReceived 等待短信完全接收
func (msg *Messaging) waitForSMSReceived(ctx context.Context, conn *dbus.Conn, path dbus.ObjectPath, interval time.Duration) (*SMS, error) {
if interval <= 0 {
interval = 100 * time.Millisecond
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
sms, err := msg.retrieveWithConn(conn, path)
if err != nil {
return nil, err
}
if sms.State == SMSStateReceived {
return sms, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
}
}
}
// Retrieve 获取短信详情
func (msg *Messaging) Retrieve(objectPath dbus.ObjectPath) (*SMS, error) {
return msg.retrieveWithConn(msg.modem.conn, objectPath)
}
// Delete 删除短信
func (msg *Messaging) Delete(path dbus.ObjectPath) error {
return msg.modem.dbusObject.Call(ModemMessagingInterface+".Delete", 0, path).Err
}
+139
View File
@@ -0,0 +1,139 @@
package modem
import (
"fmt"
"github.com/godbus/dbus/v5"
)
const ModemInterface = ModemManagerInterface + ".Modem"
// Modem 调制解调器
type Modem struct {
conn *dbus.Conn
objectPath dbus.ObjectPath
dbusObject dbus.BusObject
EquipmentIdentifier string // IMEI
Model string
Manufacturer string
Number string // MSISDN
SignalQuality uint32 // 信号质量 (0-100)
OperatorName string // 运营商名称
ICCID string // SIM 卡 ICCID
}
// NewModem 创建调制解调器实例
func NewModem(conn *dbus.Conn, path dbus.ObjectPath) (*Modem, error) {
obj := conn.Object(ModemManagerInterface, path)
modem := &Modem{
conn: conn,
objectPath: path,
dbusObject: obj,
}
// 获取设备信息
if variant, err := obj.GetProperty(ModemInterface + ".EquipmentIdentifier"); err == nil {
modem.EquipmentIdentifier = variant.Value().(string)
}
if variant, err := obj.GetProperty(ModemInterface + ".Model"); err == nil {
modem.Model = variant.Value().(string)
}
if variant, err := obj.GetProperty(ModemInterface + ".Manufacturer"); err == nil {
modem.Manufacturer = variant.Value().(string)
}
// 尝试获取 MSISDN
if variant, err := obj.GetProperty(ModemInterface + ".OwnNumbers"); err == nil {
if numbers, ok := variant.Value().([]string); ok && len(numbers) > 0 {
modem.Number = numbers[0]
}
}
// 获取信号质量
modem.UpdateSignalQuality()
// 获取运营商名称
modem.UpdateOperatorName()
// 获取 ICCID
modem.UpdateICCID()
return modem, nil
}
// Path 返回 DBus 路径
func (m *Modem) Path() dbus.ObjectPath {
return m.objectPath
}
// Messaging 返回消息接口
func (m *Modem) Messaging() *Messaging {
return &Messaging{modem: m}
}
// String 返回调制解调器描述
func (m *Modem) String() string {
if m.Model != "" {
return fmt.Sprintf("%s (%s)", m.Model, m.EquipmentIdentifier)
}
return m.EquipmentIdentifier
}
// UpdateSignalQuality 更新信号质量
func (m *Modem) UpdateSignalQuality() {
if variant, err := m.dbusObject.GetProperty(ModemInterface + ".SignalQuality"); err == nil {
// SignalQuality 返回 (quality, recent) 元组
if tuple, ok := variant.Value().([]interface{}); ok && len(tuple) >= 1 {
if quality, ok := tuple[0].(uint32); ok {
m.SignalQuality = quality
}
}
}
}
// UpdateOperatorName 更新运营商名称
func (m *Modem) UpdateOperatorName() {
const Modem3gppInterface = ModemManagerInterface + ".Modem.Modem3gpp"
if variant, err := m.dbusObject.GetProperty(Modem3gppInterface + ".OperatorName"); err == nil {
if name, ok := variant.Value().(string); ok {
m.OperatorName = name
}
}
}
// UpdateICCID 更新 SIM 卡 ICCID
func (m *Modem) UpdateICCID() {
const SimInterface = ModemManagerInterface + ".Sim"
// 首先获取 Sim 对象路径
if variant, err := m.dbusObject.GetProperty(ModemInterface + ".Sim"); err == nil {
if simPath, ok := variant.Value().(dbus.ObjectPath); ok && simPath != "/" {
// 获取 Sim 对象的 SimIdentifier 属性
simObj := m.conn.Object(ModemManagerInterface, simPath)
if simVariant, err := simObj.GetProperty(SimInterface + ".SimIdentifier"); err == nil {
if iccid, ok := simVariant.Value().(string); ok {
m.ICCID = iccid
}
}
}
}
}
// RunUSSD 执行 USSD 代码
func (m *Modem) RunUSSD(code string) (string, error) {
const UssdInterface = ModemManagerInterface + ".Modem.Modem3gpp.Ussd"
// 调用 Initiate 方法
// Initiate (s command) -> (s reply)
var reply string
err := m.dbusObject.Call(UssdInterface+".Initiate", 0, code).Store(&reply)
if err != nil {
return "", fmt.Errorf("USSD 执行失败: %w", err)
}
// 有些 Modem 可能不通过返回值返回结果,而是通过 Respond 方法或信号
// 但通常 Initiate 会等待直到会话结束或返回
return reply, nil
}
+101
View File
@@ -0,0 +1,101 @@
package modem
import (
"time"
"github.com/godbus/dbus/v5"
)
const ModemSMSInterface = ModemManagerInterface + ".Sms"
// SMSState 短信状态
type SMSState uint32
const (
SMSStateUnknown SMSState = iota // 未知状态
SMSStateStored // 已存储
SMSStateReceiving // 接收中
SMSStateReceived // 已接收
SMSStateSending // 发送中
SMSStateSent // 已发送
)
// String 返回状态描述
func (s SMSState) String() string {
switch s {
case SMSStateUnknown:
return "unknown"
case SMSStateStored:
return "stored"
case SMSStateReceiving:
return "receiving"
case SMSStateReceived:
return "received"
case SMSStateSending:
return "sending"
case SMSStateSent:
return "sent"
default:
return "unknown"
}
}
// SMS 短信
type SMS struct {
objectPath dbus.ObjectPath
State SMSState
Number string
Text string
Timestamp time.Time
}
// Path 返回 DBus 路径
func (sms *SMS) Path() dbus.ObjectPath {
return sms.objectPath
}
// retrieveWithConn 使用指定连接获取短信
func (msg *Messaging) retrieveWithConn(conn *dbus.Conn, objectPath dbus.ObjectPath) (*SMS, error) {
obj := conn.Object(ModemManagerInterface, objectPath)
sms := SMS{objectPath: objectPath}
// 获取状态
variant, err := obj.GetProperty(ModemSMSInterface + ".State")
if err != nil {
return nil, err
}
sms.State = SMSState(variant.Value().(uint32))
// 获取号码
variant, err = obj.GetProperty(ModemSMSInterface + ".Number")
if err != nil {
return nil, err
}
sms.Number = variant.Value().(string)
// 获取文本
variant, err = obj.GetProperty(ModemSMSInterface + ".Text")
if err != nil {
return nil, err
}
sms.Text = variant.Value().(string)
// 获取时间戳
variant, err = obj.GetProperty(ModemSMSInterface + ".Timestamp")
if err != nil {
return nil, err
}
if t := variant.Value().(string); t != "" {
// 处理时区格式(+08:00 需要转为 +08:00)
if len(t) >= 3 && (t[len(t)-3] == '+' || t[len(t)-3] == '-') {
t = t + ":00"
}
sms.Timestamp, err = time.Parse(time.RFC3339, t)
if err != nil {
return nil, err
}
}
return &sms, nil
}
+58
View File
@@ -0,0 +1,58 @@
package notifier
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"github.com/sms-forwarder/internal/config"
)
// BarkChannel Bark 通道
type BarkChannel struct {
cfg config.ChannelConfig
}
// NewBarkChannel 创建 Bark 通道
func NewBarkChannel(cfg config.ChannelConfig) (*BarkChannel, error) {
if cfg.Endpoint == "" {
return nil, fmt.Errorf("Bark endpoint 未配置")
}
return &BarkChannel{cfg: cfg}, nil
}
// Type 返回通道类型
func (b *BarkChannel) Type() string {
return "bark"
}
// Send 发送 Bark 通知
func (b *BarkChannel) Send(msg Message) error {
title := msg.From
if title == "" {
title = "未知号码"
}
payload := map[string]interface{}{
"title": title,
"body": msg.String(),
}
data, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := http.Post(b.cfg.Endpoint, "application/json", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Bark 返回错误状态: %d", resp.StatusCode)
}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package notifier
import (
"crypto/tls"
"fmt"
"net/smtp"
"strings"
"github.com/sms-forwarder/internal/config"
)
// EmailChannel Email 通道
type EmailChannel struct {
cfg config.ChannelConfig
}
// NewEmailChannel 创建 Email 通道
func NewEmailChannel(cfg config.ChannelConfig) (*EmailChannel, error) {
if cfg.Host == "" || cfg.Port == 0 || cfg.From == "" || len(cfg.To) == 0 {
return nil, fmt.Errorf("Email 配置不完整")
}
if cfg.UseTLS {
cfg.UseTLS = true // 默认使用 TLS
}
return &EmailChannel{cfg: cfg}, nil
}
// Type 返回通道类型
func (e *EmailChannel) Type() string {
return "email"
}
// Send 发送邮件
func (e *EmailChannel) Send(msg Message) error {
subject := msg.From
if subject == "" {
subject = "未知号码"
}
body := msg.String()
// 构建邮件内容
headers := make(map[string]string)
headers["From"] = e.cfg.From
headers["To"] = strings.Join(e.cfg.To, ",")
headers["Subject"] = subject
headers["MIME-Version"] = "1.0"
headers["Content-Type"] = "text/plain; charset=UTF-8"
var message strings.Builder
for k, v := range headers {
message.WriteString(fmt.Sprintf("%s: %s\r\n", k, v))
}
message.WriteString("\r\n")
message.WriteString(body)
// 发送邮件
addr := fmt.Sprintf("%s:%d", e.cfg.Host, e.cfg.Port)
var auth smtp.Auth
if e.cfg.Username != "" && e.cfg.Password != "" {
auth = smtp.PlainAuth("", e.cfg.Username, e.cfg.Password, e.cfg.Host)
}
if e.cfg.UseTLS {
// 使用 TLS
tlsConfig := &tls.Config{
ServerName: e.cfg.Host,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("TLS 连接失败: %w", err)
}
defer conn.Close()
client, err := smtp.NewClient(conn, e.cfg.Host)
if err != nil {
return fmt.Errorf("创建 SMTP 客户端失败: %w", err)
}
defer client.Quit()
if auth != nil {
if err := client.Auth(auth); err != nil {
return fmt.Errorf("认证失败: %w", err)
}
}
if err := client.Mail(e.cfg.From); err != nil {
return err
}
for _, to := range e.cfg.To {
if err := client.Rcpt(to); err != nil {
return err
}
}
w, err := client.Data()
if err != nil {
return err
}
_, err = w.Write([]byte(message.String()))
if err != nil {
return err
}
return w.Close()
}
// 不使用 TLS
return smtp.SendMail(addr, auth, e.cfg.From, e.cfg.To, []byte(message.String()))
}
+63
View File
@@ -0,0 +1,63 @@
package notifier
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"github.com/sms-forwarder/internal/config"
)
// GotifyChannel Gotify 通道
type GotifyChannel struct {
cfg config.ChannelConfig
}
// NewGotifyChannel 创建 Gotify 通道
func NewGotifyChannel(cfg config.ChannelConfig) (*GotifyChannel, error) {
if cfg.Endpoint == "" || cfg.Token == "" {
return nil, fmt.Errorf("Gotify 配置不完整")
}
if cfg.Priority == 0 {
cfg.Priority = 5
}
return &GotifyChannel{cfg: cfg}, nil
}
// Type 返回通道类型
func (g *GotifyChannel) Type() string {
return "gotify"
}
// Send 发送 Gotify 通知
func (g *GotifyChannel) Send(msg Message) error {
url := fmt.Sprintf("%s/message?token=%s", g.cfg.Endpoint, g.cfg.Token)
title := msg.From
if title == "" {
title = "未知号码"
}
payload := map[string]interface{}{
"title": title,
"message": msg.String(),
"priority": g.cfg.Priority,
}
data, err := json.Marshal(payload)
if err != nil {
return err
}
resp, err := http.Post(url, "application/json", bytes.NewReader(data))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("Gotify 返回错误状态: %d", resp.StatusCode)
}
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package notifier
import (
"fmt"
"log/slog"
"time"
"github.com/sms-forwarder/internal/config"
)
// Message 通知消息
type Message struct {
Modem string
From string
To string
Timestamp time.Time
Text string
Incoming bool
}
// String 返回纯文本格式
func (m Message) String() string {
return fmt.Sprintf(
"%s\n\n发送人: %s\n时间: %s",
m.displayText(),
m.From,
m.formatTimestamp(),
)
}
func (m Message) displayText() string {
if m.Text == "" {
return "(空消息)"
}
return m.Text
}
func (m Message) formatTimestamp() string {
if m.Timestamp.IsZero() {
return "未知"
}
return m.Timestamp.Format("2006-01-02 15:04:05")
}
// Notifier 通知器
type Notifier struct {
channels []Channel
}
// New 创建通知器
func New(cfg *config.Config) (*Notifier, error) {
channels := make([]Channel, 0, len(cfg.Channels))
for _, chCfg := range cfg.Channels {
if !chCfg.Enabled {
slog.Info("跳过未启用的通道", "type", chCfg.Type)
continue
}
ch, err := createChannel(chCfg)
if err != nil {
slog.Error("创建通道失败", "type", chCfg.Type, "error", err)
continue
}
channels = append(channels, ch)
slog.Info("已加载通道", "type", chCfg.Type)
}
return &Notifier{
channels: channels,
}, nil
}
// Send 发送通知
func (n *Notifier) Send(msg Message) error {
if len(n.channels) == 0 {
return nil
}
var lastErr error
for _, ch := range n.channels {
if err := ch.Send(msg); err != nil {
slog.Error("通道发送失败", "type", ch.Type(), "error", err)
lastErr = err
continue
}
slog.Info("通知已发送", "type", ch.Type())
}
return lastErr
}
// createChannel 根据配置创建通道
func createChannel(cfg config.ChannelConfig) (Channel, error) {
switch cfg.Type {
case "email":
return NewEmailChannel(cfg)
case "bark":
return NewBarkChannel(cfg)
case "gotify":
return NewGotifyChannel(cfg)
case "serverchan":
return NewServerChanChannel(cfg)
case "webhook":
return NewWebhookChannel(cfg)
default:
return nil, fmt.Errorf("未知通道类型: %s", cfg.Type)
}
}
// Channel 通知通道接口
type Channel interface {
Type() string
Send(msg Message) error
}
+48
View File
@@ -0,0 +1,48 @@
package notifier
import (
"fmt"
serverchan_sdk "github.com/easychen/serverchan-sdk-golang"
"github.com/sms-forwarder/internal/config"
)
// ServerChanChannel ServerChan 通道
type ServerChanChannel struct {
cfg config.ChannelConfig
}
// NewServerChanChannel 创建 ServerChan 通道
func NewServerChanChannel(cfg config.ChannelConfig) (*ServerChanChannel, error) {
if cfg.SendKey == "" {
return nil, fmt.Errorf("ServerChan SendKey 未配置")
}
return &ServerChanChannel{cfg: cfg}, nil
}
// Type 返回通道类型
func (s *ServerChanChannel) Type() string {
return "serverchan"
}
// Send 发送 ServerChan 通知
func (s *ServerChanChannel) Send(msg Message) error {
title := msg.From
if title == "" {
title = "未知号码"
}
markdown := msg.String()
resp, err := serverchan_sdk.ScSend(s.cfg.SendKey, title, markdown, nil)
if err != nil {
return err
}
if resp == nil {
return fmt.Errorf("ServerChan 返回空响应")
}
if resp.Code != 0 {
return fmt.Errorf("ServerChan 返回错误: %s", resp.Message)
}
return nil
}
+100
View File
@@ -0,0 +1,100 @@
package notifier
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/sms-forwarder/internal/config"
)
// WebhookChannel Webhook 通道
type WebhookChannel struct {
cfg config.ChannelConfig
}
// NewWebhookChannel 创建 Webhook 通道
func NewWebhookChannel(cfg config.ChannelConfig) (*WebhookChannel, error) {
if cfg.URL == "" {
return nil, fmt.Errorf("Webhook URL 未配置")
}
if cfg.Method == "" {
cfg.Method = "POST"
}
return &WebhookChannel{cfg: cfg}, nil
}
// Type 返回通道类型
func (w *WebhookChannel) Type() string {
return "webhook"
}
// Send 发送 Webhook 通知
func (w *WebhookChannel) Send(msg Message) error {
formattedTime := "未知"
if !msg.Timestamp.IsZero() {
formattedTime = msg.Timestamp.Format("2006-01-02 15:04:05")
}
payload := map[string]interface{}{
"time": formattedTime,
"from": msg.From,
"text": msg.Text,
}
var req *http.Request
var err error
if strings.ToUpper(w.cfg.Method) == "GET" {
// GET 请求
params := url.Values{}
params.Set("time", formattedTime)
params.Set("from", msg.From)
params.Set("text", msg.Text)
fullURL := w.cfg.URL
if strings.Contains(fullURL, "?") {
fullURL += "&" + params.Encode()
} else {
fullURL += "?" + params.Encode()
}
req, err = http.NewRequest("GET", fullURL, nil)
} else {
// POST 请求
data, err := json.Marshal(payload)
if err != nil {
return err
}
req, err = http.NewRequest("POST", w.cfg.URL, bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
}
if err != nil {
return err
}
// 添加自定义头
for k, v := range w.cfg.Headers {
req.Header.Set(k, v)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("Webhook 返回错误状态: %d", resp.StatusCode)
}
return nil
}
+441
View File
@@ -0,0 +1,441 @@
package server
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"log/slog"
"net/http"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/sms-forwarder/internal/config"
"github.com/sms-forwarder/internal/forwarder"
"github.com/sms-forwarder/internal/modem"
"github.com/sms-forwarder/internal/notifier"
"github.com/sms-forwarder/internal/storage"
)
//go:embed web/*
var webFiles embed.FS
// Server Web 服务器
type Server struct {
cfg *config.Config
forwarder *forwarder.Forwarder
storage *storage.Storage
server *http.Server
configPath string
clients map[*websocket.Conn]bool
clientsMu sync.RWMutex
upgrader websocket.Upgrader
}
// New 创建服务器
func New(cfg *config.Config, fwd *forwarder.Forwarder, store *storage.Storage, configPath string) *Server {
return &Server{
cfg: cfg,
forwarder: fwd,
storage: store,
configPath: configPath,
clients: make(map[*websocket.Conn]bool),
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // 允许所有源
},
},
}
}
// Run 运行服务器
func (s *Server) Run(ctx context.Context) error {
if !s.cfg.Server.Enabled {
<-ctx.Done()
return nil
}
mux := http.NewServeMux()
// API 路由
mux.HandleFunc("/api/modems", s.handleModems)
mux.HandleFunc("/api/messages", s.handleMessages)
mux.HandleFunc("/api/messages/delete", s.handleDeleteMessage)
mux.HandleFunc("/api/channels", s.handleChannels)
mux.HandleFunc("/api/channels/save", s.handleSaveChannels)
mux.HandleFunc("/api/channels/test", s.handleTestChannel)
mux.HandleFunc("/api/ussd", s.handleUSSD)
mux.HandleFunc("/ws", s.handleWebSocket)
// 静态文件 - 使用 web 子目录
webFS, err := fs.Sub(webFiles, "web")
if err != nil {
return err
}
mux.Handle("/", http.FileServer(http.FS(webFS)))
s.server = &http.Server{
Addr: s.cfg.Server.Listen,
Handler: mux,
}
slog.Info("Web 服务器启动", "listen", s.cfg.Server.Listen)
// 启动服务器
go func() {
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("服务器错误", "error", err)
}
}()
<-ctx.Done()
// 优雅关闭
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return s.server.Shutdown(shutdownCtx)
}
// ModemInfo 调制解调器信息
type ModemInfo struct {
IMEI string `json:"imei"`
Model string `json:"model"`
Manufacturer string `json:"manufacturer"`
Number string `json:"number"`
SignalQuality uint32 `json:"signal_quality"`
OperatorName string `json:"operator_name"`
ICCID string `json:"iccid"`
}
// handleModems 处理调制解调器信息请求
func (s *Server) handleModems(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
modems := s.forwarder.GetModems()
infos := make([]ModemInfo, 0, len(modems))
for _, modem := range modems {
// 更新实时信息
modem.UpdateSignalQuality()
modem.UpdateOperatorName()
modem.UpdateICCID()
info := ModemInfo{
IMEI: modem.EquipmentIdentifier,
Model: modem.Model,
Manufacturer: modem.Manufacturer,
Number: modem.Number,
SignalQuality: modem.SignalQuality,
OperatorName: modem.OperatorName,
ICCID: modem.ICCID,
}
infos = append(infos, info)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(infos)
}
// handleMessages 处理短信列表请求
func (s *Server) handleMessages(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
if s.storage == nil {
json.NewEncoder(w).Encode(map[string]interface{}{
"items": []storage.Message{},
"total": 0,
})
return
}
// 解析分页参数
query := r.URL.Query()
limit := 50
offset := 0
if l := query.Get("limit"); l != "" {
fmt.Sscanf(l, "%d", &limit)
}
if o := query.Get("offset"); o != "" {
fmt.Sscanf(o, "%d", &offset)
}
messages, total := s.storage.ListWithPagination(limit, offset)
json.NewEncoder(w).Encode(map[string]interface{}{
"items": messages,
"total": total,
})
}
// handleUSSD 处理 USSD 请求
func (s *Server) handleUSSD(w http.ResponseWriter, r *http.Request) {
// Helper to send JSON error
sendError := func(w http.ResponseWriter, msg string, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(map[string]string{
"error": msg,
})
}
if r.Method != http.MethodPost {
sendError(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var req struct {
IMEI string `json:"imei"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
sendError(w, "Invalid request", http.StatusBadRequest)
return
}
modems := s.forwarder.GetModems()
var targetModem *modem.Modem
for _, m := range modems {
if m.EquipmentIdentifier == req.IMEI {
targetModem = m
break
}
}
if targetModem == nil {
sendError(w, "Device not found", http.StatusNotFound)
return
}
slog.Info("执行 USSD", "imei", req.IMEI, "code", req.Code)
reply, err := targetModem.RunUSSD(req.Code)
if err != nil {
slog.Error("USSD 执行失败", "error", err)
sendError(w, "执行失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"reply": reply,
})
}
// handleDeleteMessage 处理删除短信请求
func (s *Server) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if s.storage == nil {
http.Error(w, "Storage not enabled", http.StatusServiceUnavailable)
return
}
var req struct {
ID string `json:"id"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if err := s.storage.Delete(req.ID); err != nil {
http.Error(w, err.Error(), http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// handleChannels 处理获取通道配置请求
func (s *Server) handleChannels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// 定义所有预设通道模板
channels := []config.ChannelConfig{
{
Type: "email",
Enabled: false,
Port: 587,
UseTLS: true,
},
{
Type: "bark",
Enabled: false,
},
{
Type: "gotify",
Enabled: false,
Priority: 5,
},
{
Type: "serverchan",
Enabled: false,
},
{
Type: "webhook",
Enabled: false,
Method: "POST",
},
}
// 将配置文件中的值合并到模板中
for i := range channels {
for j := range s.cfg.Channels {
if channels[i].Type == s.cfg.Channels[j].Type {
channels[i] = s.cfg.Channels[j]
break
}
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(channels)
}
// handleSaveChannels 处理保存通道配置请求
func (s *Server) handleSaveChannels(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var channels []config.ChannelConfig
if err := json.NewDecoder(r.Body).Decode(&channels); err != nil {
http.Error(w, "Invalid request: "+err.Error(), http.StatusBadRequest)
return
}
// 直接保存所有通道(包括未启用的)
s.cfg.Channels = channels
// 保存到配置文件
if err := s.cfg.Save(s.configPath); err != nil {
http.Error(w, "Failed to save config: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// handleTestChannel 处理测试通道请求
func (s *Server) handleTestChannel(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var channels []config.ChannelConfig
if err := json.NewDecoder(r.Body).Decode(&channels); err != nil {
http.Error(w, "Invalid request: "+err.Error(), http.StatusBadRequest)
return
}
// 创建通知发送器(包含所有要测试的通道)
testCfg := &config.Config{
Channels: channels,
}
n, err := notifier.New(testCfg)
if err != nil {
http.Error(w, "创建通知器失败: "+err.Error(), http.StatusInternalServerError)
return
}
// 创建测试消息
testMsg := notifier.Message{
Modem: "测试设备",
From: "测试号码",
Text: "这是一条测试推送消息,如果您收到此消息,说明推送通道配置正确。",
Timestamp: time.Now(),
Incoming: true,
}
// 发送测试消息
if err := n.Send(testMsg); err != nil {
http.Error(w, "测试失败: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"success": true,
"message": "测试消息已发送",
})
}
// handleWebSocket 处理 WebSocket 连接
func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
slog.Error("WebSocket 升级失败", "error", err)
return
}
// 注册客户端
s.clientsMu.Lock()
s.clients[conn] = true
s.clientsMu.Unlock()
slog.Info("WebSocket 客户端连接", "remote", r.RemoteAddr)
// 客户端断开时清理
defer func() {
s.clientsMu.Lock()
delete(s.clients, conn)
s.clientsMu.Unlock()
conn.Close()
slog.Info("WebSocket 客户端断开", "remote", r.RemoteAddr)
}()
// 保持连接并处理 ping/pong
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}
// BroadcastMessage 广播新消息给所有 WebSocket 客户端
func (s *Server) BroadcastMessage(msg storage.Message) {
s.clientsMu.RLock()
defer s.clientsMu.RUnlock()
data, err := json.Marshal(map[string]interface{}{
"type": "new_message",
"message": msg,
})
if err != nil {
slog.Error("序列化消息失败", "error", err)
return
}
for client := range s.clients {
err := client.WriteMessage(websocket.TextMessage, data)
if err != nil {
slog.Error("发送 WebSocket 消息失败", "error", err)
}
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+199
View File
@@ -0,0 +1,199 @@
package storage
import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"github.com/sms-forwarder/internal/modem"
)
// Message 存储的短信消息
type Message struct {
ID string `json:"id"`
Modem string `json:"modem"` // 调制解调器 IMEI
Number string `json:"number"` // 发送方号码
Text string `json:"text"` // 短信内容
Timestamp time.Time `json:"timestamp"` // 接收时间
Saved time.Time `json:"saved"` // 保存时间
}
// Storage 短信存储
type Storage struct {
path string
mu sync.Mutex
messages []Message
ids map[string]bool // 用于快速查重
messageHandler func(Message) // 新消息回调
}
// New 创建存储实例
func New(path string) (*Storage, error) {
s := &Storage{
path: path,
messages: []Message{},
ids: make(map[string]bool),
}
// 尝试加载已有数据
if err := s.load(); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("加载存储文件: %w", err)
}
return s, nil
}
// GenerateID 生成消息唯一 ID
func GenerateID(modemIMEI string, sms *modem.SMS) string {
// 使用 MD5(IMEI + Timestamp + Number + Text) 作为唯一 ID
// 这样即使同一秒收到两方发来的消息(或者同一方发的两条不同消息),也能区分
data := fmt.Sprintf("%s|%d|%s|%s", modemIMEI, sms.Timestamp.Unix(), sms.Number, sms.Text)
hash := md5.Sum([]byte(data))
return hex.EncodeToString(hash[:])
}
// Save 保存短信
func (s *Storage) Save(modemIMEI string, sms *modem.SMS) error {
s.mu.Lock()
defer s.mu.Unlock()
id := GenerateID(modemIMEI, sms)
// 检查是否已存在
if s.ids[id] {
return nil // 已存在,跳过
}
msg := Message{
ID: id,
Modem: modemIMEI,
Number: sms.Number,
Text: sms.Text,
Timestamp: sms.Timestamp,
Saved: time.Now(),
}
s.messages = append(s.messages, msg)
s.ids[id] = true
// 触发新消息回调(在解锁后执行,避免阻塞)
// 注意:为了简单起见,这里仍在持有锁的情况下启动 goroutine,但 goroutine 内部不应再请求此锁
if s.messageHandler != nil {
go s.messageHandler(msg)
}
return s.save()
}
// Has 检查消息是否存在
func (s *Storage) Has(modemIMEI string, sms *modem.SMS) bool {
s.mu.Lock()
defer s.mu.Unlock()
id := GenerateID(modemIMEI, sms)
return s.ids[id]
}
// SetMessageHandler 设置新消息处理器
func (s *Storage) SetMessageHandler(handler func(Message)) {
s.mu.Lock()
defer s.mu.Unlock()
s.messageHandler = handler
}
// List 列出所有短信
func (s *Storage) List() []Message {
s.mu.Lock()
defer s.mu.Unlock()
result := make([]Message, len(s.messages))
copy(result, s.messages)
return result
}
// ListWithPagination 分页获取短信 (按时间倒序)
func (s *Storage) ListWithPagination(limit, offset int) ([]Message, int64) {
s.mu.Lock()
defer s.mu.Unlock()
total := int64(len(s.messages))
if total == 0 {
return []Message{}, 0
}
// 既然需要倒序(最新的在前),offset=0 代表 slice 的最后一个元素
// index = len - 1 - offset
start := int64(len(s.messages)) - 1 - int64(offset)
if start < 0 {
return []Message{}, total
}
end := start - int64(limit) + 1
if end < 0 {
end = 0
}
// 收集结果
count := start - end + 1
result := make([]Message, 0, count)
for i := start; i >= end; i-- {
result = append(result, s.messages[i])
}
return result, total
}
// Delete 删除短信
func (s *Storage) Delete(id string) error {
s.mu.Lock()
defer s.mu.Unlock()
for i, msg := range s.messages {
if msg.ID == id {
// 删除该消息
s.messages = append(s.messages[:i], s.messages[i+1:]...)
delete(s.ids, id)
return s.save()
}
}
return fmt.Errorf("消息不存在")
}
// load 从文件加载
func (s *Storage) load() error {
data, err := os.ReadFile(s.path)
if err != nil {
return err
}
if err := json.Unmarshal(data, &s.messages); err != nil {
return err
}
// 重建索引
s.ids = make(map[string]bool)
for _, msg := range s.messages {
s.ids[msg.ID] = true
}
return nil
}
// save 保存到文件
func (s *Storage) save() error {
data, err := json.MarshalIndent(s.messages, "", " ")
if err != nil {
return err
}
return os.WriteFile(s.path, data, 0644)
}
// Close 关闭存储
func (s *Storage) Close() error {
return nil
}
+88
View File
@@ -0,0 +1,88 @@
package main
import (
"context"
"flag"
"log"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/sms-forwarder/internal/config"
"github.com/sms-forwarder/internal/forwarder"
"github.com/sms-forwarder/internal/modem"
"github.com/sms-forwarder/internal/server"
"github.com/sms-forwarder/internal/storage"
)
func main() {
configFile := flag.String("config", "config.toml", "配置文件路径")
flag.Parse()
// 加载配置
cfg, err := config.Load(*configFile)
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
// 初始化调制解调器管理器
manager, err := modem.NewManager()
if err != nil {
log.Fatalf("初始化 ModemManager 失败: %v", err)
}
// 初始化存储
var store *storage.Storage
if cfg.Storage.Enabled {
store, err = storage.New(cfg.Storage.Path)
if err != nil {
log.Fatalf("初始化存储失败: %v", err)
}
defer store.Close()
}
// 初始化转发器
fwd, err := forwarder.New(cfg, manager, store)
if err != nil {
log.Fatalf("初始化转发器失败: %v", err)
}
// 初始化 Web 服务器
srv := server.New(cfg, fwd, store, *configFile)
// 设置新消息处理器
if store != nil {
store.SetMessageHandler(func(msg storage.Message) {
srv.BroadcastMessage(msg)
})
}
// 创建上下文
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 启动转发器
go func() {
if err := fwd.Run(ctx); err != nil {
slog.Error("转发器运行错误", "error", err)
}
}()
// 启动 Web 服务器
go func() {
if err := srv.Run(ctx); err != nil {
slog.Error("服务器运行错误", "error", err)
}
}()
slog.Info("SMS 转发器已启动")
// 等待信号
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
<-sigCh
slog.Info("收到停止信号,正在关闭...")
cancel()
}
Binary file not shown.
Binary file not shown.