fix: harden panel self-upgrade (#506)

This fixes panel self-upgrade and monitor realtime access for permitted
non-admin users.

Changes:
- Reuse GitHub proxy config for system upgrade release/API fetches.
- Make panel self-upgrade helper fail fast on compose command errors.
- Allow users with explicit monitor permission to connect to the
realtime websocket and receive broadcasts, not just admins.

Validation:
- cd go-backend && GOCACHE=/private/tmp/flvx-gocache go test
./internal/http/handler -count=1
- cd go-backend && go test ./internal/ws -count=1
This commit is contained in:
sagit
2026-05-15 23:54:27 +08:00
committed by GitHub
4 changed files with 309 additions and 93 deletions
@@ -31,6 +31,10 @@ const (
var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) var safeBackendContainerPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`)
var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`) var enableIPv6ComposePattern = regexp.MustCompile(`(?im)^\s*enable_ipv6\s*:\s*['"]?true['"]?\s*(?:#.*)?$`)
var systemUpgradeReleaseBaseURL = githubHTMLBase var systemUpgradeReleaseBaseURL = githubHTMLBase
var systemUpgradeAPIBaseURL = githubAPIBase
var systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
return client.Get(url)
}
type systemUpgradeExecutor struct { type systemUpgradeExecutor struct {
deployDir string deployDir string
@@ -199,7 +203,7 @@ if [ ! -f .env ]; then
fi fi
log "拉取新镜像..." log "拉取新镜像..."
if ! docker compose pull backend frontend 2>&1 | tee -a "$LOGFILE"; then if ! docker compose pull backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 拉取镜像失败" log "错误: 拉取镜像失败"
exit 1 exit 1
fi fi
@@ -208,7 +212,7 @@ log "等待旧容器释放资源..."
sleep 3 sleep 3
log "重启服务(force-recreate)..." log "重启服务(force-recreate)..."
if ! docker compose up -d --force-recreate --remove-orphans backend frontend 2>&1 | tee -a "$LOGFILE"; then if ! docker compose up -d --force-recreate --remove-orphans backend frontend >> "$LOGFILE" 2>&1; then
log "错误: 重启服务失败" log "错误: 重启服务失败"
exit 1 exit 1
fi fi
@@ -317,6 +321,68 @@ func (e *systemUpgradeExecutor) replaceCompose(path string, data []byte) error {
return writeFileWithMode(path, data, mode) return writeFileWithMode(path, data, mode)
} }
func (h *Handler) buildSystemUpgradeDownloadURL(version, filename string) string {
enabled, proxyURL := h.getGithubProxyConfig()
base := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename)
if enabled {
return fmt.Sprintf("%s/%s", proxyURL, base)
}
return base
}
func (h *Handler) fetchSystemUpgradeReleases(perPage int) ([]githubRelease, error) {
if perPage <= 0 {
perPage = 20
}
client := &http.Client{Timeout: 15 * time.Second}
url := fmt.Sprintf("%s/repos/%s/releases?per_page=%d", strings.TrimRight(systemUpgradeAPIBaseURL, "/"), githubRepo, perPage)
if enabled, proxyURL := h.getGithubProxyConfig(); enabled {
url = fmt.Sprintf("%s/%s", proxyURL, url)
}
resp, err := systemUpgradeHTTPGet(client, url)
if err != nil {
return nil, fmt.Errorf("请求GitHub API失败: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("GitHub API返回 %d: %s", resp.StatusCode, string(body))
}
var releases []githubRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, fmt.Errorf("解析GitHub API响应失败: %v", err)
}
return releases, nil
}
func (h *Handler) resolveSystemUpgradeLatestReleaseByChannel(channel string) (string, error) {
normalizedChannel := normalizeReleaseChannel(channel)
releases, err := h.fetchSystemUpgradeReleases(50)
if err != nil {
return "", err
}
for _, r := range releases {
if r.Draft {
continue
}
tag := strings.TrimSpace(r.TagName)
if tag == "" {
continue
}
if releaseChannelFromTag(tag) == normalizedChannel {
return tag, nil
}
}
return "", fmt.Errorf("未找到%s版本号", releaseChannelLabel(normalizedChannel))
}
func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) { func fileModeOrDefault(path string, fallback os.FileMode) (os.FileMode, error) {
info, err := os.Stat(path) info, err := os.Stat(path)
if err != nil { if err != nil {
@@ -367,9 +433,9 @@ func (e *systemUpgradeExecutor) startHelper(ctx context.Context, imageID, helper
} }
func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) { func (h *Handler) downloadReleaseAsset(version, filename string) ([]byte, error) {
url := fmt.Sprintf("%s/%s/releases/download/%s/%s", strings.TrimRight(systemUpgradeReleaseBaseURL, "/"), githubRepo, version, filename) url := h.buildSystemUpgradeDownloadURL(version, filename)
client := &http.Client{Timeout: 60 * time.Second} client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Get(url) resp, err := systemUpgradeHTTPGet(client, url)
if err != nil { if err != nil {
return nil, fmt.Errorf("下载%s失败: %v", filename, err) return nil, fmt.Errorf("下载%s失败: %v", filename, err)
} }
@@ -457,7 +523,7 @@ func (h *Handler) systemVersion(w http.ResponseWriter, r *http.Request) {
current := currentPanelVersion() current := currentPanelVersion()
exec := newSystemUpgradeExecutor() exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context()) capability := exec.capability(r.Context())
latest, err := resolveLatestReleaseByChannel(channel) latest, err := h.resolveSystemUpgradeLatestReleaseByChannel(channel)
response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability))) response.WriteJSON(w, response.OK(systemUpgradeVersionResponse(current, channel, latest, err, capability)))
} }
@@ -477,7 +543,7 @@ func (h *Handler) systemCheckUpdates(w http.ResponseWriter, r *http.Request) {
exec := newSystemUpgradeExecutor() exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context()) capability := exec.capability(r.Context())
githubReleases, err := fetchGitHubReleases(50) githubReleases, err := h.fetchSystemUpgradeReleases(50)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err))) response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取版本列表失败: %v", err)))
return return
@@ -514,22 +580,21 @@ func (h *Handler) systemUpgrade(w http.ResponseWriter, r *http.Request) {
return return
} }
channel := normalizeReleaseChannel(req.Channel) channel := normalizeReleaseChannel(req.Channel)
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = resolveLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
exec := newSystemUpgradeExecutor() exec := newSystemUpgradeExecutor()
capability := exec.capability(r.Context()) capability := exec.capability(r.Context())
if !capability.Capable { if !capability.Capable {
response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; "))) response.WriteJSON(w, response.ErrDefault("当前环境不支持面板自升级: "+strings.Join(capability.Reasons, "; ")))
return return
} }
version := strings.TrimSpace(req.Version)
if version == "" {
var err error
version, err = h.resolveSystemUpgradeLatestReleaseByChannel(channel)
if err != nil {
response.WriteJSON(w, response.Err(-2, fmt.Sprintf("获取最新%s失败: %v", releaseChannelLabel(channel), err)))
return
}
}
imageID, err := exec.currentBackendImage(r.Context()) imageID, err := exec.currentBackendImage(r.Context())
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -3,6 +3,7 @@ package handler
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
@@ -10,6 +11,9 @@ import (
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"time"
"go-backend/internal/store/repo"
) )
func TestSelectComposeAssetUsesIPv6Template(t *testing.T) { func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
@@ -21,19 +25,39 @@ func TestSelectComposeAssetUsesIPv6Template(t *testing.T) {
} }
} }
func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) { func TestDownloadReleaseAssetUsesGithubProxyWhenEnabled(t *testing.T) {
var gotPath string dbPath := filepath.Join(t.TempDir(), "test.db")
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { repoStore, err := repo.Open(dbPath)
gotPath = r.URL.Path if err != nil {
_, _ = w.Write([]byte("services:\n backend:\n image: test\n")) t.Fatalf("repo.Open() error = %v", err)
})) }
defer server.Close() defer repoStore.Close()
h := &Handler{repo: repoStore}
originalBase := systemUpgradeReleaseBaseURL originalBase := systemUpgradeReleaseBaseURL
systemUpgradeReleaseBaseURL = server.URL systemUpgradeReleaseBaseURL = "https://example.invalid"
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase }) t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
h := &Handler{} originalGet := systemUpgradeHTTPGet
defer func() { systemUpgradeHTTPGet = originalGet }()
var gotURL string
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
gotURL = url
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("services:\n backend:\n image: test\n")),
}, nil
}
now := time.Now().UnixMilli()
if err := repoStore.UpsertConfig("github_proxy_enabled", "true", now); err != nil {
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
}
if err := repoStore.UpsertConfig("github_proxy_url", "https://proxy.example.com", now); err != nil {
t.Fatalf("UpsertConfig() github_proxy_url error = %v", err)
}
data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml") data, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
if err != nil { if err != nil {
t.Fatalf("downloadReleaseAsset() error = %v", err) t.Fatalf("downloadReleaseAsset() error = %v", err)
@@ -42,24 +66,39 @@ func TestDownloadReleaseAssetUsesDirectReleaseURL(t *testing.T) {
t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data)) t.Fatalf("downloadReleaseAsset() data = %q, want compose data", string(data))
} }
wantPath := "/" + githubRepo + "/releases/download/2.1.9/docker-compose-v4.yml" wantURL := "https://proxy.example.com/https://example.invalid/Sagit-chu/flvx/releases/download/2.1.9/docker-compose-v4.yml"
if gotPath != wantPath { if gotURL != wantURL {
t.Fatalf("download path = %q, want %q", gotPath, wantPath) t.Fatalf("download URL = %q, want %q", gotURL, wantURL)
} }
} }
func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) { func TestDownloadReleaseAssetRejectsOversizedBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { dbPath := filepath.Join(t.TempDir(), "test.db")
_, _ = w.Write(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1)) repoStore, err := repo.Open(dbPath)
})) if err != nil {
defer server.Close() t.Fatalf("repo.Open() error = %v", err)
}
defer repoStore.Close()
now := time.Now().UnixMilli()
if err := repoStore.UpsertConfig("github_proxy_enabled", "false", now); err != nil {
t.Fatalf("UpsertConfig() github_proxy_enabled error = %v", err)
}
originalBase := systemUpgradeReleaseBaseURL originalBase := systemUpgradeReleaseBaseURL
systemUpgradeReleaseBaseURL = server.URL systemUpgradeReleaseBaseURL = "https://example.invalid"
t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase }) t.Cleanup(func() { systemUpgradeReleaseBaseURL = originalBase })
h := &Handler{} originalGet := systemUpgradeHTTPGet
_, err := h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml") defer func() { systemUpgradeHTTPGet = originalGet }()
systemUpgradeHTTPGet = func(client *http.Client, url string) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader(bytes.Repeat([]byte("a"), maxSystemUpgradeComposeAssetBytes+1))),
}, nil
}
h := &Handler{repo: repoStore}
_, err = h.downloadReleaseAsset("2.1.9", "docker-compose-v4.yml")
if err == nil || !strings.Contains(err.Error(), "过大") { if err == nil || !strings.Contains(err.Error(), "过大") {
t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err) t.Fatalf("downloadReleaseAsset() error = %v, want oversized error", err)
} }
@@ -272,7 +311,7 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
fakeDockerDir := t.TempDir() fakeDockerDir := t.TempDir()
fakeDockerPath := filepath.Join(fakeDockerDir, "docker") fakeDockerPath := filepath.Join(fakeDockerDir, "docker")
fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;\n *)\n exit 0\n ;;\n esac\n" fakeDockerScript := "#!/bin/sh\ncase \"$1\" in\n --version)\n echo 'Docker version 27.0.0'\n exit 0\n ;;&\n compose)\n if [ \"$2\" = version ]; then\n echo 'Docker Compose version v2.33.0'\n exit 0\n fi\n exit 0\n ;;&\n inspect)\n echo 'No such object: flux-panel-backend' >&2\n exit 1\n ;;&\n *)\n exit 0\n ;;&\n esac\n"
if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil { if err := os.WriteFile(fakeDockerPath, []byte(fakeDockerScript), 0o755); err != nil {
t.Fatalf("WriteFile() fake docker error = %v", err) t.Fatalf("WriteFile() fake docker error = %v", err)
} }
@@ -281,7 +320,7 @@ func TestSystemUpgradeFailsFastBeforeMutatingFiles(t *testing.T) {
t.Setenv(panelBackendContainerEnv, "flux-panel-backend") t.Setenv(panelBackendContainerEnv, "flux-panel-backend")
h := &Handler{} h := &Handler{}
req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable","version":"3.0.0"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/system/upgrade", strings.NewReader(`{"channel":"stable"}`))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
h.systemUpgrade(rr, req) h.systemUpgrade(rr, req)
+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()
}