576 lines
14 KiB
Go
576 lines
14 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"encoding/json"
|
|
"errors"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/CyberPigeon/internal/config"
|
|
"github.com/CyberPigeon/internal/forwarder"
|
|
"github.com/CyberPigeon/internal/modem"
|
|
"github.com/CyberPigeon/internal/notifier"
|
|
"github.com/CyberPigeon/internal/storage"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
//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
|
|
writeMu sync.Mutex // 保护 WebSocket 并发写
|
|
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 isOriginAllowed(r, cfg.Server.AllowedOrigins) },
|
|
},
|
|
}
|
|
}
|
|
|
|
func isOriginAllowed(r *http.Request, allowedOrigins []string) bool {
|
|
origin := strings.TrimSpace(r.Header.Get("Origin"))
|
|
if origin == "" {
|
|
return true
|
|
}
|
|
|
|
u, err := url.Parse(origin)
|
|
if err != nil || u.Host == "" {
|
|
return false
|
|
}
|
|
|
|
if len(allowedOrigins) == 0 {
|
|
return strings.EqualFold(u.Host, r.Host)
|
|
}
|
|
|
|
for _, allowed := range allowedOrigins {
|
|
allowed = strings.TrimSpace(allowed)
|
|
if allowed == "" {
|
|
continue
|
|
}
|
|
if allowed == "*" || strings.EqualFold(allowed, origin) || strings.EqualFold(allowed, u.Host) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// 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)
|
|
|
|
// 启动服务器
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("服务器错误", "error", err)
|
|
errCh <- err
|
|
}
|
|
}()
|
|
|
|
select {
|
|
case <-ctx.Done():
|
|
case err := <-errCh:
|
|
return err
|
|
}
|
|
|
|
// 优雅关闭
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
if err := s.server.Shutdown(shutdownCtx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 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 != "" {
|
|
v, err := strconv.Atoi(l)
|
|
if err != nil {
|
|
http.Error(w, "Invalid limit", http.StatusBadRequest)
|
|
return
|
|
}
|
|
limit = v
|
|
}
|
|
if o := query.Get("offset"); o != "" {
|
|
v, err := strconv.Atoi(o)
|
|
if err != nil {
|
|
http.Error(w, "Invalid offset", http.StatusBadRequest)
|
|
return
|
|
}
|
|
offset = v
|
|
}
|
|
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
if limit > 200 {
|
|
limit = 200
|
|
}
|
|
if offset < 0 {
|
|
offset = 0
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 校验 USSD 代码格式(通常以 * 或 # 开头,以 # 结尾,只包含数字、*、#)
|
|
if req.Code == "" {
|
|
sendError(w, "USSD code is empty", http.StatusBadRequest)
|
|
return
|
|
}
|
|
for _, c := range req.Code {
|
|
if c != '*' && c != '#' && (c < '0' || c > '9') {
|
|
sendError(w, "Invalid USSD code format, only digits, * and # are allowed", 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
|
|
}
|
|
|
|
// 定义所有预设通道模板(用于补齐用户未配置的通道类型)
|
|
defaultChannels := map[string]config.ChannelConfig{
|
|
"email": {
|
|
Type: "email",
|
|
Enabled: false,
|
|
RequestTimeoutSec: 10,
|
|
Port: 587,
|
|
UseTLS: true,
|
|
},
|
|
"bark": {
|
|
Type: "bark",
|
|
Enabled: false,
|
|
RequestTimeoutSec: 10,
|
|
},
|
|
"gotify": {
|
|
Type: "gotify",
|
|
Enabled: false,
|
|
RequestTimeoutSec: 10,
|
|
Priority: 5,
|
|
},
|
|
"serverchan": {
|
|
Type: "serverchan",
|
|
Enabled: false,
|
|
RequestTimeoutSec: 10,
|
|
},
|
|
"webhook": {
|
|
Type: "webhook",
|
|
Enabled: false,
|
|
RequestTimeoutSec: 10,
|
|
AllowPrivateNetwork: false,
|
|
Method: "POST",
|
|
},
|
|
"wecom": {
|
|
Type: "wecom",
|
|
Enabled: false,
|
|
RequestTimeoutSec: 10,
|
|
},
|
|
}
|
|
|
|
// 以配置文件中的通道为基础,保留所有已配置的通道(含同类型多个)
|
|
channels := make([]config.ChannelConfig, len(s.cfg.Channels))
|
|
copy(channels, s.cfg.Channels)
|
|
|
|
// 记录已配置的类型
|
|
configuredTypes := make(map[string]bool)
|
|
for _, ch := range channels {
|
|
configuredTypes[ch.Type] = true
|
|
}
|
|
|
|
// 补齐未配置的类型
|
|
for typeName, defaultCh := range defaultChannels {
|
|
if !configuredTypes[typeName] {
|
|
channels = append(channels, defaultCh)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 先热重载转发器中的通知器
|
|
if err := s.forwarder.ReloadChannels(channels); err != nil {
|
|
http.Error(w, "Failed to reload channels: "+err.Error(), http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// 保存到配置文件
|
|
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)
|
|
}()
|
|
|
|
// 设置心跳检测
|
|
const (
|
|
pongWait = 60 * time.Second
|
|
pingPeriod = 50 * time.Second // 必须小于 pongWait
|
|
)
|
|
|
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
conn.SetPongHandler(func(string) error {
|
|
conn.SetReadDeadline(time.Now().Add(pongWait))
|
|
return nil
|
|
})
|
|
|
|
// 启动 Ping 发送 goroutine
|
|
done := make(chan struct{})
|
|
go func() {
|
|
ticker := time.NewTicker(pingPeriod)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ticker.C:
|
|
s.writeMu.Lock()
|
|
err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(10*time.Second))
|
|
s.writeMu.Unlock()
|
|
if err != nil {
|
|
return
|
|
}
|
|
case <-done:
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
defer close(done)
|
|
|
|
// 保持连接并处理消息
|
|
for {
|
|
_, _, err := conn.ReadMessage()
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// BroadcastMessage 广播新消息给所有 WebSocket 客户端
|
|
func (s *Server) BroadcastMessage(msg storage.Message) {
|
|
data, err := json.Marshal(map[string]interface{}{
|
|
"type": "new_message",
|
|
"message": msg,
|
|
})
|
|
if err != nil {
|
|
slog.Error("序列化消息失败", "error", err)
|
|
return
|
|
}
|
|
|
|
s.clientsMu.RLock()
|
|
clients := make([]*websocket.Conn, 0, len(s.clients))
|
|
for client := range s.clients {
|
|
clients = append(clients, client)
|
|
}
|
|
s.clientsMu.RUnlock()
|
|
|
|
for _, client := range clients {
|
|
s.writeMu.Lock()
|
|
err := client.WriteMessage(websocket.TextMessage, data)
|
|
s.writeMu.Unlock()
|
|
if err != nil {
|
|
slog.Error("发送 WebSocket 消息失败", "error", err)
|
|
s.clientsMu.Lock()
|
|
delete(s.clients, client)
|
|
s.clientsMu.Unlock()
|
|
_ = client.Close()
|
|
}
|
|
}
|
|
}
|