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;