diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 0000000..b0b4215 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,98 @@ +package main + +import ( + "crypto/subtle" + "net/http" + "os" + "path/filepath" + "strings" +) + +type AuthConfig struct { + tokens []string +} + +func NewAuthConfig() AuthConfig { + return AuthConfig{tokens: loadAuthTokens()} +} + +func (a AuthConfig) Required() bool { + return len(a.tokens) > 0 +} + +func (a AuthConfig) Authorize(r *http.Request) bool { + if !a.Required() { + return true + } + token := requestToken(r) + if token == "" { + return false + } + for _, expected := range a.tokens { + if constantTimeTokenEqual(token, expected) { + return true + } + } + return false +} + +func loadAuthTokens() []string { + candidates := []string{ + os.Getenv("AI_WORKSPACE_AUTH_TOKEN"), + os.Getenv("XWORKSPACE_CONSOLE_AUTH_TOKEN"), + os.Getenv("XWORKMATE_BRIDGE_AUTH_TOKEN"), + os.Getenv("BRIDGE_AUTH_TOKEN"), + os.Getenv("BRIDGE_REVIEW_AUTH_TOKEN"), + os.Getenv("INTERNAL_SERVICE_TOKEN"), + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, + readTokenFile(filepath.Join(home, ".ai_workspace_auth_token")), + readTokenFile(filepath.Join(home, ".config", "xworkspace", "auth-token")), + ) + } + + seen := map[string]struct{}{} + tokens := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + token := strings.TrimSpace(candidate) + if token == "" { + continue + } + if _, ok := seen[token]; ok { + continue + } + seen[token] = struct{}{} + tokens = append(tokens, token) + } + return tokens +} + +func readTokenFile(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" + } + return strings.TrimSpace(string(content)) +} + +func requestToken(r *http.Request) string { + header := strings.TrimSpace(r.Header.Get("Authorization")) + if len(header) > 7 && strings.EqualFold(header[:7], "Bearer ") { + return strings.TrimSpace(header[7:]) + } + if header != "" { + return header + } + if token := strings.TrimSpace(r.Header.Get("X-Bridge-Token")); token != "" { + return token + } + return strings.TrimSpace(r.Header.Get("X-XWorkspace-Token")) +} + +func constantTimeTokenEqual(actual string, expected string) bool { + if len(actual) != len(expected) { + return false + } + return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1 +} diff --git a/api/portal_services.go b/api/portal_services.go new file mode 100644 index 0000000..593b79a --- /dev/null +++ b/api/portal_services.go @@ -0,0 +1,114 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" +) + +type PortalServiceProvider struct { + file string +} + +func NewPortalServiceProvider() PortalServiceProvider { + file := os.Getenv("XWORKSPACE_PORTAL_SERVICES_FILE") + if file == "" { + if home, err := os.UserHomeDir(); err == nil { + file = filepath.Join(home, ".config", "xworkspace", "portal-services.json") + } + } + return PortalServiceProvider{file: file} +} + +func (p PortalServiceProvider) Services() []PortalService { + if services := p.loadFromFile(); len(services) > 0 { + return services + } + return defaultPortalServices() +} + +func (p PortalServiceProvider) loadFromFile() []PortalService { + if p.file == "" { + return nil + } + content, err := os.ReadFile(p.file) + if err != nil { + return nil + } + + var wrapper PortalServicesResponse + if err := json.Unmarshal(content, &wrapper); err == nil && len(wrapper.Services) > 0 { + return normalizePortalServices(wrapper.Services) + } + + var services []PortalService + if err := json.Unmarshal(content, &services); err == nil && len(services) > 0 { + return normalizePortalServices(services) + } + return nil +} + +func normalizePortalServices(services []PortalService) []PortalService { + normalized := make([]PortalService, 0, len(services)) + for _, service := range services { + if service.Key == "" || service.Name == "" || service.URL == "" { + continue + } + if service.OpenMode != "external" { + service.OpenMode = "iframe" + } + normalized = append(normalized, service) + } + return normalized +} + +func defaultPortalServices() []PortalService { + return []PortalService{ + { + Key: "litellm", + Name: "LiteLLM Admin UI", + URL: "http://localhost:4000/ui", + OpenMode: "iframe", + HealthURL: "http://127.0.0.1:4000/ui", + Description: "Model routing and provider administration.", + Icon: "chart", + Match: []string{"litellm", "lite"}, + Port: 4000, + Role: "model-router", + }, + { + Key: "openclaw", + Name: "OpenClaw", + URL: "http://127.0.0.1:18789/channels", + OpenMode: "external", + HealthURL: "http://127.0.0.1:18789/channels", + Description: "Gateway dashboard.", + Icon: "claw", + Match: []string{"openclaw", "gateway"}, + Port: 18789, + Role: "gateway", + }, + { + Key: "vault", + Name: "Vault Server", + URL: "http://127.0.0.1:8200/ui", + OpenMode: "external", + HealthURL: "http://127.0.0.1:8200/ui", + Description: "Vault UI.", + Icon: "shield", + Match: []string{"vault"}, + Port: 8200, + }, + { + Key: "terminal", + Name: "Terminal", + URL: "http://127.0.0.1:7681", + OpenMode: "iframe", + HealthURL: "http://127.0.0.1:7681", + Description: "Local ttyd terminal.", + Icon: "terminal", + Match: []string{"ttyd", "terminal"}, + Port: 7681, + }, + } +} diff --git a/api/proxy.go b/api/proxy.go deleted file mode 100644 index 17877b4..0000000 --- a/api/proxy.go +++ /dev/null @@ -1,57 +0,0 @@ -package main - -import ( - "net/http" - "net/http/httputil" - "net/url" - "strings" -) - -func NewLocalProxy(target string, prefix string) http.Handler { - upstream, err := url.Parse(target) - if err != nil { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - http.Error(w, err.Error(), http.StatusInternalServerError) - }) - } - - proxy := httputil.NewSingleHostReverseProxy(upstream) - originalDirector := proxy.Director - proxy.Director = func(r *http.Request) { - originalDirector(r) - r.URL.Scheme = upstream.Scheme - r.URL.Host = upstream.Host - r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix) - if r.URL.Path == "" { - r.URL.Path = "/" - } - r.Host = upstream.Host - } - proxy.ModifyResponse = func(resp *http.Response) error { - stripFrameBlockingHeaders(resp.Header) - return nil - } - return proxy -} - -func stripFrameBlockingHeaders(header http.Header) { - header.Del("X-Frame-Options") - csp := header.Get("Content-Security-Policy") - if csp == "" { - return - } - header.Set("Content-Security-Policy", removeCSPDirective(csp, "frame-ancestors")) -} - -func removeCSPDirective(csp string, directive string) string { - parts := strings.Split(csp, ";") - kept := parts[:0] - for _, part := range parts { - trimmed := strings.TrimSpace(part) - if trimmed == "" || strings.HasPrefix(strings.ToLower(trimmed), directive) { - continue - } - kept = append(kept, trimmed) - } - return strings.Join(kept, "; ") -} diff --git a/api/server.go b/api/server.go index 153d7e8..b01f7bf 100644 --- a/api/server.go +++ b/api/server.go @@ -8,15 +8,19 @@ import ( ) type Server struct { - serviceProbe ServiceProbe - metricProbe MetricProbe + serviceProbe ServiceProbe + metricProbe MetricProbe + portalServices PortalServiceProvider + auth AuthConfig } func NewServer() *Server { serviceProbe := NewServiceProbe() return &Server{ - serviceProbe: serviceProbe, - metricProbe: NewMetricProbe(), + serviceProbe: serviceProbe, + metricProbe: NewMetricProbe(), + portalServices: NewPortalServiceProvider(), + auth: NewAuthConfig(), } } @@ -26,12 +30,11 @@ func (s *Server) ListenAndServe(addr string) error { func (s *Server) routes() http.Handler { mux := http.NewServeMux() + mux.HandleFunc("/auth/status", s.authStatusHandler) mux.HandleFunc("/health", s.healthHandler) mux.HandleFunc("/services", s.servicesHandler) + mux.HandleFunc("/portal/services", s.portalServicesHandler) mux.HandleFunc("/metrics/simple", s.metricsHandler) - mux.Handle("/proxy/openclaw/", NewLocalProxy("http://127.0.0.1:18789", "/proxy/openclaw")) - mux.Handle("/proxy/vault/", NewLocalProxy("http://127.0.0.1:8200", "/proxy/vault")) - mux.Handle("/ui/", NewLocalProxy("http://127.0.0.1:8200", "")) return mux } @@ -44,7 +47,7 @@ func (s *Server) withCORS(next http.Handler) http.Handler { w.Header().Set("Access-Control-Allow-Origin", "http://127.0.0.1:17000") } w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Bridge-Token, X-XWorkspace-Token") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return @@ -62,7 +65,14 @@ func allowedOrigin(origin string) bool { } } +func (s *Server) authStatusHandler(w http.ResponseWriter, r *http.Request) { + writeJSON(w, AuthStatusResponse{Required: s.auth.Required()}) +} + func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth(w, r) { + return + } services := s.serviceProbe.Probe() writeJSON(w, HealthResponse{ Status: "ok", @@ -77,13 +87,36 @@ func (s *Server) healthHandler(w http.ResponseWriter, r *http.Request) { } func (s *Server) servicesHandler(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth(w, r) { + return + } writeJSON(w, s.serviceProbe.Probe()) } +func (s *Server) portalServicesHandler(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth(w, r) { + return + } + writeJSON(w, PortalServicesResponse{Services: s.portalServices.Services()}) +} + func (s *Server) metricsHandler(w http.ResponseWriter, r *http.Request) { + if !s.requireAuth(w, r) { + return + } _, _ = w.Write([]byte("xworkspace_systemd_services " + strconv.Itoa(len(s.serviceProbe.Probe())) + "\n")) } +func (s *Server) requireAuth(w http.ResponseWriter, r *http.Request) bool { + if s.auth.Authorize(r) { + return true + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) + return false +} + func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") enc := json.NewEncoder(w) diff --git a/api/types.go b/api/types.go index e7a4c9e..35a9abb 100644 --- a/api/types.go +++ b/api/types.go @@ -9,6 +9,27 @@ type Service struct { URL string `json:"url,omitempty"` } +type PortalService struct { + Key string `json:"key"` + Name string `json:"name"` + URL string `json:"url"` + OpenMode string `json:"openMode"` + HealthURL string `json:"healthUrl,omitempty"` + Description string `json:"description,omitempty"` + Icon string `json:"icon,omitempty"` + Match []string `json:"match,omitempty"` + Port int `json:"port,omitempty"` + Role string `json:"role,omitempty"` +} + +type PortalServicesResponse struct { + Services []PortalService `json:"services"` +} + +type AuthStatusResponse struct { + Required bool `json:"required"` +} + type HealthResponse struct { Status string `json:"status"` Arch string `json:"arch"` diff --git a/config/systemd/user/xworkspace-api.service b/config/systemd/user/xworkspace-api.service index 12aa627..66e2dd2 100644 --- a/config/systemd/user/xworkspace-api.service +++ b/config/systemd/user/xworkspace-api.service @@ -6,7 +6,9 @@ Wants=network-online.target [Service] Type=simple WorkingDirectory=%h/xworkspace-console/api -ExecStart=/usr/local/go/bin/go run . +EnvironmentFile=-%h/.config/xworkspace/portal.env +Environment=XWORKSPACE_PORTAL_SERVICES_FILE=%h/.config/xworkspace/portal-services.json +ExecStart=/usr/bin/env go run . Restart=always RestartSec=2 diff --git a/dashboard/src/components/AppShell.tsx b/dashboard/src/components/AppShell.tsx index 72e65f1..ec551de 100644 --- a/dashboard/src/components/AppShell.tsx +++ b/dashboard/src/components/AppShell.tsx @@ -1,14 +1,25 @@ 'use client'; import { useEffect, useMemo, useState } from 'react'; -import { customWorkspaceTabs, fallbackMetrics, initialTabs, labelsEn, labelsZh } from '@/lib/data'; -import type { NavItem, RuntimeMetrics, Service, Tab } from '@/lib/data'; -import { fetchDashboardStatus } from '@/lib/api'; +import { + buildInitialTabs, + buildNavSections, + customWorkspaceTabs, + fallbackMetrics, + findPortalService, + initialTabs, + labelsEn, + labelsZh, + portalServices, + portalServiceToTab, +} from '@/lib/data'; +import type { NavItem, PortalService, RuntimeMetrics, Service, Tab } from '@/lib/data'; +import { fetchAuthStatus, fetchDashboardStatus, fetchPortalServices, validateBridgeToken } from '@/lib/api'; import { Sidebar } from './Sidebar'; import { Topbar } from './Topbar'; import { WorkspaceTabs } from './WorkspaceTabs'; import { WorkspaceHome } from './WorkspaceHome'; -import { EmbedView } from './EmbedView'; +import { ServicePanel } from './ServicePanel'; export function AppShell() { const [selectedTab, setSelectedTab] = useState('workspace'); @@ -19,14 +30,33 @@ export function AppShell() { const [language, setLanguage] = useState<'en' | 'zh'>('en'); const [theme, setTheme] = useState<'light' | 'dark'>('light'); const [remoteMode, setRemoteMode] = useState(true); + const [portalServicesConfig, setPortalServicesConfig] = useState(portalServices); + const [authRequired, setAuthRequired] = useState(false); + const [authStatusLoaded, setAuthStatusLoaded] = useState(false); + const [authToken, setAuthToken] = useState(''); + const [tokenInput, setTokenInput] = useState(''); + const [tokenLoaded, setTokenLoaded] = useState(false); + const [authError, setAuthError] = useState(false); + const [authChecking, setAuthChecking] = useState(false); useEffect(() => { let active = true; const refresh = () => { - fetchDashboardStatus().then((data) => { - if (!active || !data) return; - setServices(data.services); - setMetrics(data.metrics); + if (!tokenLoaded || !authStatusLoaded || (authRequired && !authToken)) return; + Promise.all([fetchDashboardStatus(authToken), fetchPortalServices(authToken)]).then(([statusResult, portalResult]) => { + if (!active) return; + if (statusResult.unauthorized || portalResult.unauthorized) { + setAuthRequired(true); + setAuthError(true); + return; + } + if (statusResult.data) { + setServices(statusResult.data.services); + setMetrics(statusResult.data.metrics); + } + if (portalResult.data?.length) { + setPortalServicesConfig(portalResult.data); + } }); }; refresh(); @@ -35,6 +65,29 @@ export function AppShell() { active = false; window.clearInterval(timer); }; + }, [authRequired, authStatusLoaded, authToken, tokenLoaded]); + + useEffect(() => { + setTabs((existingTabs) => { + const workspaceTab = existingTabs.find((tab) => tab.id === 'workspace') ?? buildInitialTabs([])[0]; + const customTabs = existingTabs.filter((tab) => tab.id !== 'workspace' && !tab.serviceKey); + return [workspaceTab, ...portalServicesConfig.map(portalServiceToTab), ...customTabs]; + }); + const activeServiceKey = selectedTab.startsWith('service-') ? selectedTab.replace(/^service-/, '') : undefined; + if (activeServiceKey && !findPortalService(activeServiceKey, portalServicesConfig)) { + setSelectedTab('workspace'); + } + }, [portalServicesConfig]); + + useEffect(() => { + const storedToken = window.localStorage.getItem('xworkspace-bridge-token') ?? ''; + setAuthToken(storedToken); + setTokenInput(storedToken); + setTokenLoaded(true); + fetchAuthStatus().then((status) => { + if (status?.required) setAuthRequired(true); + setAuthStatusLoaded(true); + }); }, []); useEffect(() => { @@ -71,7 +124,9 @@ export function AppShell() { const currentServices = services ?? []; const selected = tabs.find((tab) => tab.id === selectedTab); + const selectedService = findPortalService(selected?.serviceKey, portalServicesConfig); const labels = language === 'zh' ? labelsZh : labelsEn; + const dynamicNavSections = useMemo(() => buildNavSections(portalServicesConfig), [portalServicesConfig]); const summary = useMemo(() => { const runningServices = currentServices.filter((service) => service.state === 'Running').length; @@ -79,11 +134,15 @@ export function AppShell() { }, [currentServices, metrics]); const openTab = (item: NavItem | Tab) => { + const service = findPortalService(item.serviceKey, portalServicesConfig); + const nextItem = service ? portalServiceToTab(service) : item; setTabs((existingTabs) => { - if (existingTabs.some((tab) => tab.id === item.id)) return existingTabs; - return [...existingTabs, { ...item, closable: true }]; + if (existingTabs.some((tab) => tab.id === nextItem.id)) { + return existingTabs.map((tab) => (tab.id === nextItem.id ? { ...tab, ...nextItem, closable: tab.closable ?? true } : tab)); + } + return [...existingTabs, { ...nextItem, closable: true }]; }); - setSelectedTab(item.id); + setSelectedTab(nextItem.id); }; const closeTab = (tabId: string) => { @@ -99,10 +158,35 @@ export function AppShell() { openTab(nextTab); }; + const submitToken = async () => { + const nextToken = tokenInput.trim(); + if (!nextToken) return; + setAuthChecking(true); + setAuthError(false); + + const portalResult = await validateBridgeToken(nextToken); + setAuthChecking(false); + + if (portalResult.unauthorized || !portalResult.data?.length) { + setAuthError(true); + return; + } + + window.localStorage.setItem('xworkspace-bridge-token', nextToken); + setPortalServicesConfig(portalResult.data); + setAuthToken(nextToken); + setTabs(buildInitialTabs(portalResult.data)); + }; + + if (tokenLoaded && authStatusLoaded && authRequired && (!authToken || authError)) { + return ; + } + return (
setSidebarCollapsed((value) => !value)} @@ -126,12 +210,53 @@ export function AppShell() { - {selected?.kind === 'embed' && selected.id !== 'workspace' ? ( - setSelectedTab('workspace')} /> + {selected?.kind === 'embed' && selectedService ? ( + setSelectedTab('workspace')} /> ) : ( - + )}
); } + +function AuthGate({ + token, + error, + checking, + onTokenChange, + onSubmit, +}: { + token: string; + error: boolean; + checking: boolean; + onTokenChange: (value: string) => void; + onSubmit: () => void | Promise; +}) { + return ( +
+
{ + event.preventDefault(); + onSubmit(); + }} + > + AI +

AI Workspace Portal

+

Enter the xworkmate-bridge token to load local services.

+ onTokenChange(event.target.value)} + placeholder="Bridge token" + aria-label="Bridge token" + disabled={checking} + /> + {error ? Token rejected by the local API. : null} + +
+
+ ); +} diff --git a/dashboard/src/components/ArchPipeline.tsx b/dashboard/src/components/ArchPipeline.tsx index 6982f93..1342be0 100644 --- a/dashboard/src/components/ArchPipeline.tsx +++ b/dashboard/src/components/ArchPipeline.tsx @@ -1,27 +1,30 @@ 'use client'; import { useState } from 'react'; -import { acpAgents, findServiceDef, serviceRegistry, skillGroups } from '@/lib/data'; -import type { Labels, NavItem, RuntimeMetrics, Service } from '@/lib/data'; +import { acpAgents, findPortalService, findPortalServiceByRole, findPortalServiceStatus, portalServiceToNavItem, skillGroups } from '@/lib/data'; +import type { Labels, NavItem, PortalService, RuntimeMetrics, Service } from '@/lib/data'; import { Icon } from './Icon'; export function ArchPipeline({ labels, services, metrics, + portalServices, onOpenService, }: { labels: Labels; services: Service[]; metrics: RuntimeMetrics; + portalServices: PortalService[]; onOpenService: (item: NavItem) => void; }) { const [skillsOpen, setSkillsOpen] = useState(false); + const gatewayService = findPortalServiceByRole('gateway', portalServices); + const modelRouterService = findPortalServiceByRole('model-router', portalServices); - const stateOf = (id: string): Service['state'] | undefined => { - const def = serviceRegistry.find((item) => item.id === id); - const service = services.find((entry) => def?.match?.some((token) => entry.name.toLowerCase().includes(token))); - return service?.state; + const openPortalService = (serviceKey?: string) => { + const serviceConfig = findPortalService(serviceKey, portalServices); + if (serviceConfig) onOpenService(portalServiceToNavItem(serviceConfig)); }; const dot = (state?: Service['state']) => ( @@ -48,16 +51,15 @@ export function ArchPipeline({ APP Chat / Web Chat
XWorkmate Bridge
- @@ -98,7 +100,7 @@ export function ArchPipeline({ 3 {labels.skillBand} Layer {totalSkills}+ {labels.skillsCount} - {dot(stateOf('bridge'))} + {dot()}
{skillGroups.map((group) => ( @@ -119,12 +121,12 @@ export function ArchPipeline({
Skills{totalSkills}+
- @@ -145,5 +147,3 @@ export function ArchPipeline({ ); } - -export { findServiceDef }; diff --git a/dashboard/src/components/EmbedView.tsx b/dashboard/src/components/EmbedView.tsx deleted file mode 100644 index 6e2cbb1..0000000 --- a/dashboard/src/components/EmbedView.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import type { Tab } from '@/lib/data'; -import { Icon } from './Icon'; - -export function EmbedView({ tab, onBack }: { tab: Tab; onBack: () => void }) { - const [reloadKey, setReloadKey] = useState(0); - const frameBlocked = tab.frameMode === 'external'; - return ( -
-
-
- - {tab.label} - {tab.href} -
- - - - -
-
- {frameBlocked ? ( -
-
- - {tab.label} -

This service blocks embedded frames. Open it in a dedicated browser tab.

- Open {tab.label} -
-
- ) : ( -