fix: allow monitor realtime websocket access

This commit is contained in:
sagitchu
2026-05-15 23:37:08 +08:00
parent 312c9a9c5c
commit b2454e86c9
2 changed files with 168 additions and 56 deletions
+127 -56
View File
@@ -30,22 +30,23 @@ type broadcastMessage struct {
Data string `json:"data"` Data string `json:"data"`
} }
type connWrap struct {
conn *websocket.Conn
mu sync.Mutex
}
type nodeSession struct { type nodeSession struct {
nodeID int64 nodeID int64
secret string secret string
conn *connWrap conn *websocket.Conn
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建 crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
} }
type adminSession struct { type adminSession struct {
userID int64 userID int64
claims auth.Claims claims auth.Claims
conn *connWrap conn *websocket.Conn
}
type monitorSession struct {
userID int64
claims auth.Claims
conn *websocket.Conn
} }
type commandResponse struct { type commandResponse struct {
@@ -83,10 +84,11 @@ type Server struct {
getUserAuthState func(userID int64) (*auth.UserAuthState, error) getUserAuthState func(userID int64) (*auth.UserAuthState, error)
mu sync.RWMutex mu sync.RWMutex
admins map[*adminSession]struct{} admins map[*adminSession]struct{}
nodes map[int64]*nodeSession monitors map[*monitorSession]struct{}
byConn map[*websocket.Conn]*nodeSession nodes map[int64]*nodeSession
pending map[string]pendingRequest byConn map[*websocket.Conn]*nodeSession
pending map[string]pendingRequest
} }
type SystemInfo struct { type SystemInfo struct {
@@ -130,10 +132,11 @@ func NewServer(repo *repo.Repository, jwtSecret string) *Server {
upgrader: websocket.Upgrader{ upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true }, CheckOrigin: func(r *http.Request) bool { return true },
}, },
admins: make(map[*adminSession]struct{}), admins: make(map[*adminSession]struct{}),
nodes: make(map[int64]*nodeSession), monitors: make(map[*monitorSession]struct{}),
byConn: make(map[*websocket.Conn]*nodeSession), nodes: make(map[int64]*nodeSession),
pending: make(map[string]pendingRequest), byConn: make(map[*websocket.Conn]*nodeSession),
pending: make(map[string]pendingRequest),
} }
} }
@@ -172,15 +175,19 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
if claims.RoleID != 0 { if claims.RoleID == 0 {
if !s.validateAdminSession(userID, claims) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
s.handleAdmin(w, r, userID, claims)
return
}
if !s.validateMonitorSession(userID, claims) {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
if !s.validateAdminSession(userID, claims) { s.handleMonitor(w, r, userID, claims)
http.Error(w, "forbidden", http.StatusForbidden)
return
}
s.handleAdmin(w, r, userID, claims)
return return
} }
@@ -192,14 +199,13 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request, userID int6
if err != nil { if err != nil {
return return
} }
cw := &connWrap{conn: conn}
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) _ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
conn.SetPongHandler(func(string) error { conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPongWait)) return conn.SetReadDeadline(time.Now().Add(wsPongWait))
}) })
done := make(chan struct{}) done := make(chan struct{})
session := &adminSession{userID: userID, claims: claims, conn: cw} session := &adminSession{userID: userID, claims: claims, conn: conn}
go startKeepalive(cw, done, func() bool { go startKeepalive(conn, done, func() bool {
return s.validateAdminSession(session.userID, session.claims) return s.validateAdminSession(session.userID, session.claims)
}) })
@@ -222,18 +228,51 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request, userID int6
} }
} }
func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64, secret string) { func (s *Server) handleMonitor(w http.ResponseWriter, r *http.Request, userID int64, claims auth.Claims) {
conn, err := s.upgrader.Upgrade(w, r, nil) conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
return return
} }
cw := &connWrap{conn: conn}
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait)) _ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
conn.SetPongHandler(func(string) error { conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPongWait)) return conn.SetReadDeadline(time.Now().Add(wsPongWait))
}) })
done := make(chan struct{}) done := make(chan struct{})
go startKeepalive(cw, done, nil) session := &monitorSession{userID: userID, claims: claims, conn: conn}
go startKeepalive(conn, done, func() bool {
return s.validateMonitorSession(session.userID, session.claims)
})
s.mu.Lock()
s.monitors[session] = struct{}{}
s.mu.Unlock()
defer func() {
close(done)
s.mu.Lock()
delete(s.monitors, session)
s.mu.Unlock()
_ = conn.Close()
}()
for {
if _, _, err := conn.ReadMessage(); err != nil {
return
}
}
}
func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64, secret string) {
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
_ = conn.SetReadDeadline(time.Now().Add(wsPongWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(wsPongWait))
})
done := make(chan struct{})
go startKeepalive(conn, done, nil)
version := r.URL.Query().Get("version") version := r.URL.Query().Get("version")
httpVal := parseIntDefault(r.URL.Query().Get("http"), 0) httpVal := parseIntDefault(r.URL.Query().Get("http"), 0)
@@ -242,15 +281,15 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
s.mu.Lock() s.mu.Lock()
if old, ok := s.nodes[nodeID]; ok { if old, ok := s.nodes[nodeID]; ok {
_ = old.conn.conn.Close() _ = old.conn.Close()
delete(s.byConn, old.conn.conn) delete(s.byConn, old.conn)
} }
// 初始化 AES 加密器并缓存(仅创建一次) // 初始化 AES 加密器并缓存(仅创建一次)
var nodeCrypto *security.AESCrypto var nodeCrypto *security.AESCrypto
if strings.TrimSpace(secret) != "" { if strings.TrimSpace(secret) != "" {
nodeCrypto, _ = security.NewAESCrypto(secret) nodeCrypto, _ = security.NewAESCrypto(secret)
} }
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw, crypto: nodeCrypto} ns := &nodeSession{nodeID: nodeID, secret: secret, conn: conn, crypto: nodeCrypto}
s.nodes[nodeID] = ns s.nodes[nodeID] = ns
s.byConn[conn] = ns s.byConn[conn] = ns
s.mu.Unlock() s.mu.Unlock()
@@ -270,7 +309,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
needOfflineBroadcast := false needOfflineBroadcast := false
s.mu.Lock() s.mu.Lock()
current, ok := s.nodes[nodeID] current, ok := s.nodes[nodeID]
if ok && current.conn.conn == conn { if ok && current.conn == conn {
delete(s.nodes, nodeID) delete(s.nodes, nodeID)
needOfflineBroadcast = true needOfflineBroadcast = true
} }
@@ -400,7 +439,7 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
s.mu.RLock() s.mu.RLock()
ns, ok := s.nodes[nodeID] ns, ok := s.nodes[nodeID]
s.mu.RUnlock() s.mu.RUnlock()
if !ok || ns == nil || ns.conn == nil || ns.conn.conn == nil { if !ok || ns == nil || ns.conn == nil {
return CommandResult{}, errors.New("节点不在线") return CommandResult{}, errors.New("节点不在线")
} }
@@ -450,11 +489,9 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
} }
} }
ns.conn.mu.Lock() _ = ns.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
_ = ns.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err = ns.conn.WriteMessage(websocket.TextMessage, messageData)
err = ns.conn.conn.WriteMessage(websocket.TextMessage, messageData) _ = ns.conn.SetWriteDeadline(time.Time{})
_ = ns.conn.conn.SetWriteDeadline(time.Time{})
ns.conn.mu.Unlock()
if err != nil { if err != nil {
cleanup() cleanup()
return CommandResult{}, err return CommandResult{}, err
@@ -570,38 +607,51 @@ func (s *Server) broadcastStatus(nodeID int64, status int) {
"data": status, "data": status,
} }
raw, _ := json.Marshal(payload) raw, _ := json.Marshal(payload)
s.broadcastToAdmins(string(raw)) s.broadcastToRealtime(string(raw))
} }
func (s *Server) broadcastInfo(nodeID int64, data string) { func (s *Server) broadcastInfo(nodeID int64, data string) {
payload := broadcastMessage{ID: nodeID, Type: "info", Data: data} payload := broadcastMessage{ID: nodeID, Type: "info", Data: data}
raw, _ := json.Marshal(payload) raw, _ := json.Marshal(payload)
s.broadcastToAdmins(string(raw)) s.broadcastToRealtime(string(raw))
} }
func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) { func (s *Server) broadcastTyped(nodeID int64, msgType string, data string) {
payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data} payload := broadcastMessage{ID: nodeID, Type: msgType, Data: data}
raw, _ := json.Marshal(payload) raw, _ := json.Marshal(payload)
s.broadcastToAdmins(string(raw)) s.broadcastToRealtime(string(raw))
} }
func (s *Server) broadcastToAdmins(message string) { func (s *Server) broadcastToRealtime(message string) {
s.mu.RLock() s.mu.RLock()
admins := make([]*adminSession, 0, len(s.admins)) admins := make([]*adminSession, 0, len(s.admins))
for c := range s.admins { for c := range s.admins {
admins = append(admins, c) admins = append(admins, c)
} }
monitors := make([]*monitorSession, 0, len(s.monitors))
for c := range s.monitors {
monitors = append(monitors, c)
}
s.mu.RUnlock() s.mu.RUnlock()
for _, c := range admins { for _, c := range admins {
if c == nil || c.conn == nil || c.conn.conn == nil { if c == nil || c.conn == nil {
continue continue
} }
c.conn.mu.Lock() _ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
_ = c.conn.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
err := c.conn.conn.WriteMessage(websocket.TextMessage, []byte(message)) _ = c.conn.SetWriteDeadline(time.Time{})
_ = c.conn.conn.SetWriteDeadline(time.Time{}) if err != nil {
c.conn.mu.Unlock() log.Printf("websocket broadcast failed: %v", err)
}
}
for _, c := range monitors {
if c == nil || c.conn == nil {
continue
}
_ = c.conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
err := c.conn.WriteMessage(websocket.TextMessage, []byte(message))
_ = c.conn.SetWriteDeadline(time.Time{})
if err != nil { if err != nil {
log.Printf("websocket broadcast failed: %v", err) log.Printf("websocket broadcast failed: %v", err)
} }
@@ -655,8 +705,31 @@ func (s *Server) validateAdminSession(userID int64, claims auth.Claims) bool {
return true return true
} }
func startKeepalive(cw *connWrap, done <-chan struct{}, validate func() bool) { func (s *Server) validateMonitorSession(userID int64, claims auth.Claims) bool {
if cw == nil || cw.conn == nil { if s == nil {
return false
}
if claims.Exp <= time.Now().Unix() {
return false
}
if s.getUserAuthState != nil {
state, err := s.getUserAuthState(userID)
if err != nil || state == nil || state.Status != 1 || state.RoleID != claims.RoleID || claims.IatMs <= state.PasswordChangedAt {
return false
}
}
if s.repo == nil {
return false
}
allowed, err := s.repo.HasMonitorPermission(userID)
if err != nil || !allowed {
return false
}
return true
}
func startKeepalive(conn *websocket.Conn, done <-chan struct{}, validate func() bool) {
if conn == nil {
return return
} }
ticker := time.NewTicker(wsPingPeriod) ticker := time.NewTicker(wsPingPeriod)
@@ -668,16 +741,14 @@ func startKeepalive(cw *connWrap, done <-chan struct{}, validate func() bool) {
return return
case <-ticker.C: case <-ticker.C:
if validate != nil && !validate() { if validate != nil && !validate() {
_ = cw.conn.Close() _ = conn.Close()
return return
} }
cw.mu.Lock() _ = conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
_ = cw.conn.SetWriteDeadline(time.Now().Add(wsWriteWait)) err := conn.WriteMessage(websocket.PingMessage, nil)
err := cw.conn.WriteMessage(websocket.PingMessage, nil) _ = conn.SetWriteDeadline(time.Time{})
_ = cw.conn.SetWriteDeadline(time.Time{})
cw.mu.Unlock()
if err != nil { if err != nil {
_ = cw.conn.Close() _ = conn.Close()
return return
} }
} }
+41
View File
@@ -4,9 +4,13 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"strings"
"testing" "testing"
"go-backend/internal/auth" "go-backend/internal/auth"
"go-backend/internal/store/repo"
"github.com/gorilla/websocket"
) )
func TestServeHTTPRejectsDisabledAdminToken(t *testing.T) { func TestServeHTTPRejectsDisabledAdminToken(t *testing.T) {
@@ -108,3 +112,40 @@ func TestValidateAdminSessionRejectsExpiredToken(t *testing.T) {
t.Fatal("expected expired token to be rejected") t.Fatal("expected expired token to be rejected")
} }
} }
func TestServeHTTPAllowsMonitorTokenWithPermission(t *testing.T) {
secret := "unit-test-secret"
token, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate token: %v", err)
}
r, err := repo.Open(t.TempDir() + "/monitor.db")
if err != nil {
t.Fatalf("open repo: %v", err)
}
defer r.Close()
if err := r.InsertMonitorPermission(2, 123); err != nil {
t.Fatalf("insert permission: %v", err)
}
server := NewServer(r, secret)
server.SetUserAuthStateLookup(func(userID int64) (*auth.UserAuthState, error) {
return &auth.UserAuthState{ID: userID, RoleID: 1, Status: 1, PasswordChangedAt: 0}, nil
})
ts := httptest.NewServer(server)
defer ts.Close()
conn, resp, err := websocket.DefaultDialer.Dial(
"ws"+strings.TrimPrefix(ts.URL, "http")+"/system-info?type=0&secret="+url.QueryEscape(token),
nil,
)
if err != nil {
if resp != nil {
t.Fatalf("dial websocket error = %v, status=%d", err, resp.StatusCode)
}
t.Fatalf("dial websocket error = %v", err)
}
_ = conn.Close()
}