fix web admin bridge websocket access (#385)
This commit is contained in:
1 parent
5a9cf6ef2a
commit
db3e2ed5ed
4 files changed
+120
-23
No files matched your search
+29
-21
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
Reference in new issue
Block a user