fix web admin bridge websocket access (#385)

This commit is contained in:
q107580018 authored and GitHub committed 2026-03-31 19:42:59 +08:00
1 parent 5a9cf6ef2a
commit db3e2ed5ed
4 files changed
+120 -23

No files matched your search

+29 -21
View File
@@ -109,9 +109,23 @@ func (m *ManagementServer) SetSaveGlobalSettings(fn func(map[string]any) error)
m.saveGlobalSettings = fn
}
func (m *ManagementServer) Start() {
mux := http.NewServeMux()
handler := m.buildHandler(mux)
m.server = &http.Server{
Addr: fmt.Sprintf(":%d", m.port),
Handler: handler,
}
go func() {
if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("management api server error", "error", err)
}
}()
slog.Info("management api started", "port", m.port)
}
func (m *ManagementServer) buildHandler(mux *http.ServeMux) http.Handler {
prefix := "/api/v1"
// System
@@ -141,18 +155,7 @@ func (m *ManagementServer) Start() {
mux.HandleFunc(prefix+"/bridge/adapters", m.wrap(m.handleBridgeAdapters))
// Static file serving for cc-connect-web (SPA)
handler := m.withStaticFallback(mux)
m.server = &http.Server{
Addr: fmt.Sprintf(":%d", m.port),
Handler: handler,
}
go func() {
if err := m.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("management api server error", "error", err)
}
}()
slog.Info("management api started", "port", m.port)
return m.withStaticFallback(mux)
}
func (m *ManagementServer) Stop() {
@@ -170,6 +173,10 @@ func (m *ManagementServer) withStaticFallback(apiMux *http.ServeMux) http.Handle
apiMux.ServeHTTP(w, r)
return
}
if m.bridgeServer != nil && r.URL.Path == m.bridgeServer.path {
m.bridgeServer.handleWS(w, r)
return
}
assets := GetWebAssets()
if assets == nil {
apiMux.ServeHTTP(w, r)
@@ -314,6 +321,7 @@ func (m *ManagementServer) handleStatus(w http.ResponseWriter, r *http.Request)
"enabled": true,
"port": m.bridgeServer.port,
"path": m.bridgeServer.path,
"token": m.bridgeServer.token,
"token_set": m.bridgeServer.token != "",
}
}
@@ -592,14 +600,14 @@ func (m *ManagementServer) handleProjectDetail(w http.ResponseWriter, r *http.Re
if r.Method == http.MethodPatch {
var body struct {
Quiet *bool `json:"quiet"`
Language *string `json:"language"`
AdminFrom *string `json:"admin_from"`
DisabledCommands []string `json:"disabled_commands"`
WorkDir *string `json:"work_dir"`
Mode *string `json:"mode"`
ShowContextIndicator *bool `json:"show_context_indicator"`
PlatformAllowFrom map[string]string `json:"platform_allow_from"`
Quiet *bool `json:"quiet"`
Language *string `json:"language"`
AdminFrom *string `json:"admin_from"`
DisabledCommands []string `json:"disabled_commands"`
WorkDir *string `json:"work_dir"`
Mode *string `json:"mode"`
ShowContextIndicator *bool `json:"show_context_indicator"`
PlatformAllowFrom map[string]string `json:"platform_allow_from"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
mgmtError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
+79
View File
@@ -191,6 +191,34 @@ func TestMgmt_Status(t *testing.T) {
}
}
func TestMgmt_StatusIncludesBridgeToken(t *testing.T) {
mgmt, ts, _ := testManagementServer(t, "tok")
mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", nil))
r := mgmtGet(t, ts.URL+"/api/v1/status", "tok")
if !r.OK {
t.Fatalf("status failed: %s", r.Error)
}
var data struct {
Bridge struct {
Enabled bool `json:"enabled"`
Port int `json:"port"`
Path string `json:"path"`
Token string `json:"token"`
} `json:"bridge"`
}
if err := json.Unmarshal(r.Data, &data); err != nil {
t.Fatalf("unmarshal status data: %v", err)
}
if !data.Bridge.Enabled {
t.Fatal("expected bridge to be enabled")
}
if data.Bridge.Token != "bridge-secret" {
t.Fatalf("expected bridge token, got %q", data.Bridge.Token)
}
}
func TestMgmt_Projects(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
@@ -482,6 +510,57 @@ func TestMgmt_CORS(t *testing.T) {
}
}
func TestMgmt_BridgeWebSocketPathProxiesToBridgeServer(t *testing.T) {
mgmt := NewManagementServer(0, "", []string{"*"})
mgmt.RegisterEngine("p", NewEngine("p", &stubAgent{}, nil, "", LangEnglish))
mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", []string{"*"}))
mux := http.NewServeMux()
ts := httptest.NewServer(mgmt.buildHandler(mux))
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/bridge/ws?token=bridge-secret", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("expected websocket upgrade, got %d", resp.StatusCode)
}
}
func TestMgmt_BridgeWebSocketPathWorksWhenBridgeServerSetAfterHandlerBuild(t *testing.T) {
mgmt := NewManagementServer(0, "", []string{"*"})
mgmt.RegisterEngine("p", NewEngine("p", &stubAgent{}, nil, "", LangEnglish))
mux := http.NewServeMux()
ts := httptest.NewServer(mgmt.buildHandler(mux))
defer ts.Close()
mgmt.SetBridgeServer(NewBridgeServer(9810, "bridge-secret", "/bridge/ws", []string{"*"}))
req, _ := http.NewRequest("GET", ts.URL+"/bridge/ws?token=bridge-secret", nil)
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "websocket")
req.Header.Set("Sec-WebSocket-Version", "13")
req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusSwitchingProtocols {
t.Fatalf("expected websocket upgrade after late bridge setup, got %d", resp.StatusCode)
}
}
func TestMgmt_MethodNotAllowed(t *testing.T) {
_, ts, _ := testManagementServer(t, "tok")
+6 -1
View File
@@ -699,11 +699,16 @@ export default function ChatView() {
{sending ? <Loader2 size={18} className="animate-spin" /> : <Send size={18} />}
</button>
</div>
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
) : !bridgeCfg ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeNotAvailable')}</span>
</div>
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeDisconnected')}</span>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
<Loader2 size={14} className="animate-spin" />
+6 -1
View File
@@ -536,11 +536,16 @@ export default function SessionChat() {
)}
</button>
</div>
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
) : !bridgeCfg ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeNotAvailable', 'Bridge not available. Enable [bridge] in config.toml to chat from web.')}</span>
</div>
) : bridgeStatus === 'disconnected' || bridgeStatus === 'error' ? (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 rounded-xl">
<WifiOff size={14} />
<span>{t('sessions.bridgeDisconnected', 'Bridge disconnected.')}</span>
</div>
) : (
<div className="flex items-center gap-2 px-4 py-3 text-sm text-gray-400 bg-gray-50 dark:bg-gray-800/50 rounded-xl">
<Loader2 size={14} className="animate-spin" />