feat: 补齐第一批已知缺口(关闭握手 / 定时备份 / Origin 白名单 / DTO 收敛)
- httpapi:新增 POST /api/shutdown(admin 会话或 X-Shutdown-Token 常量时间比对) 与 GET /api/rules/switches(recorder 可读,只回布尔开关);settings/standings 的 重复 DTO 收敛到 internal/dto;standings.showIndividualMedals 贯通到设置读写与前端 - export:抽出 export.Service,新增自动备份定时任务(backup.intervalMinutes/keep), 自动、手动、发布前三类备份共用同一把互斥锁与滚动清理 - realtime:/ws 去掉 InsecureSkipVerify,改用 OriginPatterns(同源与无 Origin 放行, 跨站需命中 server.allowedOrigins) - console:停止服务改走 POST /api/shutdown 优雅关闭(5 秒宽限后回退强杀), 服务管理器新增「已连接的大屏实例」卡片 - deploy:build.bat 改用 for /f 读内部署名——set /p 在 chcp 65001 下返回空, 导致最近这次构建的 exe 里根本没注入署名 - docs:备份策略、关闭超时、Origin 配置的说明与实现对齐
This commit is contained in:
1 parent
4d56c73ae6
commit
2c9ff65fc4
42 files changed
+1271
-275
No files matched your search
@@ -54,6 +54,7 @@ type Hub struct {
|
||||
slowLag int64
|
||||
helloTO time.Duration
|
||||
writeTO time.Duration
|
||||
allowedOrigins []string
|
||||
}
|
||||
|
||||
// NewHub 创建实时中心并启动心跳循环;db 是赛事库(change_log 所在库)。
|
||||
@@ -160,6 +161,14 @@ func (h *Hub) SetSlowLag(n int64) {
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetAllowedOrigins 设置 WebSocket 跨站 Origin 白名单(path.Match 模式);
|
||||
// 同源与不带 Origin 的握手始终放行,不受此设置影响。
|
||||
func (h *Hub) SetAllowedOrigins(origins []string) {
|
||||
h.mu.Lock()
|
||||
h.allowedOrigins = append([]string(nil), origins...)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *Hub) heartbeatIntervalValue() time.Duration {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -190,16 +199,22 @@ func (h *Hub) writeTimeout() time.Duration {
|
||||
return h.writeTO
|
||||
}
|
||||
|
||||
func (h *Hub) allowedOriginsValue() []string {
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
return h.allowedOrigins
|
||||
}
|
||||
|
||||
// Handler 返回 WebSocket 处理器,由调用方挂到 /ws 上。
|
||||
func (h *Hub) Handler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
|
||||
// 大屏常以 IP/别名访问且与管理端同端口,Origin 校验交给反向代理与鉴权层。
|
||||
InsecureSkipVerify: true,
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
// 同源与不带 Origin 的握手由库放行;跨站需命中 server.allowedOrigins 白名单。
|
||||
OriginPatterns: h.allowedOriginsValue(),
|
||||
CompressionMode: websocket.CompressionDisabled,
|
||||
})
|
||||
if err != nil {
|
||||
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "err", err)
|
||||
h.log.Debug("WebSocket 握手失败", "remote", r.RemoteAddr, "origin", r.Header.Get("Origin"), "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package realtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coder/websocket"
|
||||
)
|
||||
|
||||
// dialOrigin 以指定 Origin 发起握手;origin 为空表示不带 Origin 头(非浏览器客户端)。
|
||||
func dialOrigin(t *testing.T, srv *httptest.Server, origin string) error {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
opts := &websocket.DialOptions{}
|
||||
if origin != "" {
|
||||
opts.HTTPHeader = http.Header{"Origin": []string{origin}}
|
||||
}
|
||||
conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(srv.URL, "http")+"/ws", opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = conn.CloseNow()
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestHandlerOrigin(t *testing.T) {
|
||||
t.Run("同源放行", func(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
// httptest.Server 的 URL 即页面 Origin:scheme + host 与请求 Host 同源。
|
||||
if err := dialOrigin(t, e.srv, e.srv.URL); err != nil {
|
||||
t.Fatalf("同源 Origin 应放行: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("空 Origin 放行", func(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
if err := dialOrigin(t, e.srv, ""); err != nil {
|
||||
t.Fatalf("不带 Origin 的客户端应放行: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("跨站拒绝", func(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
if err := dialOrigin(t, e.srv, "http://evil.example"); err == nil {
|
||||
t.Fatal("跨站 Origin 应拒绝")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("白名单放行", func(t *testing.T) {
|
||||
e := newTestEnv(t)
|
||||
e.hub.SetAllowedOrigins([]string{"http://evil.example"})
|
||||
if err := dialOrigin(t, e.srv, "http://evil.example"); err != nil {
|
||||
t.Fatalf("白名单内 Origin 应放行: %v", err)
|
||||
}
|
||||
if err := dialOrigin(t, e.srv, "http://other.example"); err == nil {
|
||||
t.Fatal("白名单外的跨站 Origin 应拒绝")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user