diff --git a/internal/logging/zap.go b/internal/logging/zap.go index 2e83645..fa312ed 100644 --- a/internal/logging/zap.go +++ b/internal/logging/zap.go @@ -432,6 +432,8 @@ func routedLogPaths(module string, level zapcore.Level) []string { paths = append(paths, filepath.Join("http", "access.log")) case "timedTask": paths = append(paths, filepath.Join("timedTask", "task.log")) + case "websocket": + paths = append(paths, filepath.Join("websocket", "websocket.log")) case "error": paths = append(paths, filepath.Join("error", "error.log")) default: diff --git a/internal/logging/zap_test.go b/internal/logging/zap_test.go index 82fb969..923637d 100644 --- a/internal/logging/zap_test.go +++ b/internal/logging/zap_test.go @@ -41,6 +41,7 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) { handler, cleanup := newZapHandler(root, "application.log", Options{Level: "info", Format: "json"}, nil) logger := slog.New(handler) logger.Info("request", "mod", "http", "request_id", "req-1") + logger.Info("handshake", "mod", "websocket") logger.Error("failed", "mod", "users") cleanup() @@ -52,6 +53,7 @@ func TestZapHandlerRoutesHTTPAndErrorLogs(t *testing.T) { paths := []string{ filepath.Join(root, date, "application.log"), filepath.Join(root, date, "http", "access.log"), + filepath.Join(root, date, "websocket", "websocket.log"), filepath.Join(root, date, "users", "application.log"), filepath.Join(root, date, "error", "error.log"), } diff --git a/internal/server/gin.go b/internal/server/gin.go index 6a141aa..098fcf3 100644 --- a/internal/server/gin.go +++ b/internal/server/gin.go @@ -16,6 +16,7 @@ import ( "github.com/gin-gonic/gin" kratoshttp "github.com/go-kratos/kratos/v3/transport/http" + melody "github.com/olahol/melody" ) func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.AccessControlService, auth middleware.TokenAuthenticator, security *systemservice.SecurityService, audit *systemservice.AuditRecorder, logger *slog.Logger, version string, routes platformmodule.RouteRegistrar, ws *websocket.Server) *gin.Engine { @@ -27,7 +28,7 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access if err := engine.SetTrustedProxies(nil); err != nil && logger != nil { logger.Error("disable trusted proxies failed", "error", err) } - engine.Use(middleware.RequestMeta(), middleware.Recovery(logger), middleware.AccessLog(runtime, logger, version), middleware.CORS(runtime), middleware.ErrorAudit(logger), middleware.SecurityRateLimit(security)) + engine.Use(middleware.RequestMeta(), middleware.Recovery(logger), websocketHandshakeLogger(logger), middleware.AccessLog(runtime, logger, version), middleware.CORS(runtime), middleware.ErrorAudit(logger), middleware.SecurityRateLimit(security)) prefix := "" snapshot := runtime.Snapshot() @@ -46,16 +47,58 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access if routes != nil { routes.RegisterRoutes(public, private, engine) } + if ws != nil && logger != nil { + ws.OnConnect(func(session *melody.Session) { + request := session.Request + logger.InfoContext(request.Context(), "websocket connection established", + "mod", "websocket", "path", request.URL.Path, "remote_addr", request.RemoteAddr, + "origin", request.Header.Get("Origin"), "user_id", session.Keys["user_id"], + "request_id", session.Keys["request_id"]) + }) + ws.OnDisconnect(func(session *melody.Session) { + request := session.Request + logger.InfoContext(request.Context(), "websocket connection closed", + "mod", "websocket", "path", request.URL.Path, "remote_addr", request.RemoteAddr, + "user_id", session.Keys["user_id"], "request_id", session.Keys["request_id"]) + }) + } handleWebSocket := func(c *gin.Context) { + wsLogger := logger + hasToken := middleware.RequestToken(c, true) != "" if ws == nil || !ws.Enabled() || c.Request.URL.Path != ws.Path() { + if wsLogger != nil { + wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: endpoint unavailable", + "mod", "websocket", "path", c.Request.URL.Path, "expected_path", websocketPath(ws), + "enabled", ws != nil && ws.Enabled(), "has_token", hasToken) + } c.Status(http.StatusNotFound) return } if !middleware.AuthenticateWebSocket(c, auth) { + if wsLogger != nil { + wsLogger.WarnContext(c.Request.Context(), "websocket handshake rejected: authentication failed", + "mod", "websocket", "path", c.Request.URL.Path, "status", c.Writer.Status(), + "has_token", hasToken, "origin", c.GetHeader("Origin")) + } return } - if err := ws.HandleRequest(c.Writer, c.Request); err != nil && logger != nil { - logger.Warn("websocket request failed", "mod", "websocket", "error", err) + claims := middleware.Claims(c) + keys := map[string]any{ + "request_id": contextString(c, "request_id"), + } + if claims != nil { + keys["user_id"] = claims.ID + } + if wsLogger != nil { + wsLogger.InfoContext(c.Request.Context(), "websocket authentication accepted; upgrading connection", + "mod", "websocket", "path", c.Request.URL.Path, "origin", c.GetHeader("Origin"), + "remote_addr", c.Request.RemoteAddr, "user_id", keys["user_id"], + "request_id", keys["request_id"]) + } + if err := ws.HandleRequestWithKeys(c.Writer, c.Request, keys); err != nil && wsLogger != nil { + wsLogger.WarnContext(c.Request.Context(), "websocket upgrade failed", "mod", "websocket", + "path", c.Request.URL.Path, "origin", c.GetHeader("Origin"), "error", err, + "request_id", keys["request_id"]) } } if ws != nil && ws.Enabled() { @@ -89,6 +132,33 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access return engine } +func websocketHandshakeLogger(logger *slog.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + if logger != nil && strings.EqualFold(strings.TrimSpace(c.GetHeader("Upgrade")), "websocket") { + logger.InfoContext(c.Request.Context(), "websocket handshake packet received", + "mod", "websocket", "path", c.Request.URL.Path, "host", c.Request.Host, + "remote_addr", c.Request.RemoteAddr, "origin", c.GetHeader("Origin"), + "has_token", middleware.RequestToken(c, true) != "", + "connection", c.GetHeader("Connection"), "user_agent", c.Request.UserAgent(), + "request_id", contextString(c, "request_id")) + } + c.Next() + } +} + +func contextString(c *gin.Context, key string) string { + value, _ := c.Get(key) + text, _ := value.(string) + return text +} + +func websocketPath(ws *websocket.Server) string { + if ws == nil { + return "" + } + return ws.Path() +} + func NewGinServer(c *config.Server, engine *gin.Engine) *kratoshttp.Server { network, address := "tcp", ":8000" if c != nil && c.HTTP != nil { diff --git a/pkg/websocket/melody.go b/pkg/websocket/melody.go index 651ff70..725a30a 100644 --- a/pkg/websocket/melody.go +++ b/pkg/websocket/melody.go @@ -3,7 +3,6 @@ package websocket import ( "net/http" - "net/url" "strings" "sync" "time" @@ -80,12 +79,9 @@ func New(config Config) *Server { allowed := append([]string(nil), c.AllowOrigins...) m.Upgrader.CheckOrigin = func(r *http.Request) bool { if len(allowed) == 0 { - origin := r.Header.Get("Origin") - if origin == "" { - return true - } - parsed, err := url.Parse(origin) - return err == nil && strings.EqualFold(parsed.Host, r.Host) + // An empty allowlist means no origin restriction. Deployments that + // need a restriction can provide allow_origins explicitly. + return true } origin := r.Header.Get("Origin") for _, value := range allowed { diff --git a/pkg/websocket/melody_test.go b/pkg/websocket/melody_test.go index c9d7e8d..5755520 100644 --- a/pkg/websocket/melody_test.go +++ b/pkg/websocket/melody_test.go @@ -48,3 +48,15 @@ func TestServerReceivesAndSendsTextMessages(t *testing.T) { t.Fatal("first message handler was overwritten") } } + +func TestServerAllowsAnyOriginByDefault(t *testing.T) { + server := New(Config{}) + defer server.Close() + + request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8000/ws", nil) + request.Host = "127.0.0.1:8000" + request.Header.Set("Origin", "http://evil.example") + if !server.Melody().Upgrader.CheckOrigin(request) { + t.Fatal("origin was rejected when allow_origins is empty") + } +} diff --git a/web/src/view/systemTools/integration/config.vue b/web/src/view/systemTools/integration/config.vue index 8a58134..7d9ae0d 100644 --- a/web/src/view/systemTools/integration/config.vue +++ b/web/src/view/systemTools/integration/config.vue @@ -167,6 +167,14 @@ {{ selected.configured ? '配置已创建' : '尚未保存配置' }} + + 连接信息 + + + + + + + + + + + + 复制示例 + + + + + @@ -199,9 +239,11 @@ import { ChatLineRound, Check, Connection, + DocumentCopy, Promotion, Refresh } from '@element-plus/icons-vue' +import { useUserStore } from '@/pinia/modules/user' import { getIntegrationConfigs, saveIntegrationConfig, @@ -261,6 +303,9 @@ const loading = ref(false) const pending = reactive({}) const errors = reactive({}) const listDrafts = reactive({}) +const connectionInfoVisible = ref(false) +const connectionTestStatus = ref('idle') +const userStore = useUserStore() const selected = computed( () => @@ -271,6 +316,45 @@ const selected = computed( const integrationKey = (item) => `${item.kind}/${item.provider}` const providerMeta = (item) => TARGETS[integrationKey(item)] || TARGETS[TARGET_ORDER[0]] +const isWebSocket = (item) => integrationKey(item) === 'websocket/melody' +const websocketOrigin = () => { + if (import.meta.env.DEV && import.meta.env.VITE_SERVER_PORT) { + const configuredBase = String(import.meta.env.VITE_BASE_PATH || window.location.origin) + const backend = new URL(configuredBase, window.location.origin) + if ( + ['127.0.0.1', 'localhost'].includes(backend.hostname) && + !['127.0.0.1', 'localhost'].includes(window.location.hostname) + ) { + backend.hostname = window.location.hostname + } + backend.port = String(import.meta.env.VITE_SERVER_PORT) + backend.protocol = backend.protocol === 'https:' ? 'wss:' : 'ws:' + return `${backend.protocol}//${backend.host}` + } + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:' + return `${protocol}//${window.location.host}` +} +const connectionInfo = computed(() => { + const path = String(selected.value?.config?.path || '/ws').trim() || '/ws' + const normalizedPath = path.startsWith('/') ? path : `/${path}` + const token = String(userStore.token || '').trim() + const url = `${websocketOrigin()}${normalizedPath}${token ? `?token=${encodeURIComponent(token)}` : ''}` + return { + url, + example: `const socket = new WebSocket(${JSON.stringify(url)})\n\nsocket.onopen = () => console.log('connected')\nsocket.onmessage = (event) => console.log(event.data)` + } +}) +const connectionTestMessage = computed(() => { + if (connectionTestStatus.value === 'testing') return '等待服务器握手包...' + if (connectionTestStatus.value === 'success') return '握手成功,WebSocket 已连接。' + if (connectionTestStatus.value === 'error') return '握手失败,请查看 websocket/websocket.log。' + return '点击“测试此地址”验证浏览器到服务器的真实握手。' +}) +const connectionTestAlertType = computed(() => { + if (connectionTestStatus.value === 'success') return 'success' + if (connectionTestStatus.value === 'error') return 'error' + return 'info' +}) const operationKey = (item) => integrationKey(item) const errorKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}` const listKey = (item, fieldKey) => `${integrationKey(item)}:${fieldKey}` @@ -518,6 +602,50 @@ const testSelected = async () => { } } +const openConnectionInfo = () => { + connectionTestStatus.value = 'idle' + connectionInfoVisible.value = true +} + +const testConnectionInfo = () => { + if (connectionTestStatus.value === 'testing') return + connectionTestStatus.value = 'testing' + let settled = false + let socket + const finish = (status) => { + if (settled) return + settled = true + window.clearTimeout(timeout) + connectionTestStatus.value = status + if (status === 'success') { + ElMessage.success('WebSocket 握手成功') + socket?.close(1000, 'connection test complete') + } else { + ElMessage.error('WebSocket 握手失败,请查看独立日志') + } + } + const timeout = window.setTimeout(() => finish('error'), 8000) + try { + socket = new WebSocket(connectionInfo.value.url) + socket.onopen = () => finish('success') + socket.onerror = () => finish('error') + socket.onclose = () => { + if (!settled) finish('error') + } + } catch { + finish('error') + } +} + +const copyText = async (value) => { + try { + await navigator.clipboard.writeText(value) + ElMessage.success('已复制到剪贴板') + } catch { + ElMessage.warning('复制失败,请手动复制') + } +} + const toggleIntegration = async (item, enabled) => { if (isBusy(item)) return const previous = item.enabled @@ -754,6 +882,10 @@ onMounted(load) line-height: 1.5; } +.copy-example { + margin-top: 8px; +} + .editor-actions { display: flex; align-items: center;