diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 24dd22f..877c2bc 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -278,6 +278,10 @@ func (api *API) RegisterRoutes(r *mux.Router, bearerToken string, devMode bool) r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET") r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT") + // Integrations (API token management) + r.HandleFunc("/api/v1/integrations", api.GetIntegrations).Methods("GET") + r.HandleFunc("/api/v1/integrations/api-token/rotate", api.RotateAPIToken).Methods("POST") + // Daemon control r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST") diff --git a/internal/api/v1/handlers_integrations.go b/internal/api/v1/handlers_integrations.go new file mode 100644 index 0000000..4c23c1d --- /dev/null +++ b/internal/api/v1/handlers_integrations.go @@ -0,0 +1,44 @@ +package v1 + +import ( + "log/slog" + "net/http" + + "github.com/wild-cloud/wild-central/internal/secrets" +) + +// GetIntegrations returns integration info including the API bearer token. +func (api *API) GetIntegrations(w http.ResponseWriter, r *http.Request) { + token, err := api.secrets.GetSecret("api.bearerToken") + if err != nil { + token = "" + } + + respondJSON(w, http.StatusOK, map[string]any{ + "apiToken": map[string]any{ + "token": token, + "description": "Bearer token for authenticating with the Wild Central API. Used by Wild Cloud and other services.", + }, + }) +} + +// RotateAPIToken generates a new API bearer token and saves it to secrets. +func (api *API) RotateAPIToken(w http.ResponseWriter, r *http.Request) { + token, err := secrets.GenerateSecret(secrets.DefaultSecretLength) + if err != nil { + respondError(w, http.StatusInternalServerError, "Failed to generate token") + return + } + + if err := api.secrets.SetSecret("api.bearerToken", token); err != nil { + respondError(w, http.StatusInternalServerError, "Failed to save token") + return + } + + slog.Info("API bearer token rotated") + + respondJSON(w, http.StatusOK, map[string]any{ + "token": token, + "message": "API token rotated. Services using the old token will need to be updated.", + }) +} diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index b5e3dc5..3f19d49 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { NavLink } from 'react-router'; -import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound } from 'lucide-react'; +import { Sun, Moon, Monitor, Shield, Lock, ShieldAlert, ShieldBan, ShieldCheck, Wifi, LayoutDashboard, Globe, Network, ChevronRight, KeyRound, Plug } from 'lucide-react'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible'; import { Sidebar, @@ -95,6 +95,7 @@ export function AppSidebar() { { to: '/crowdsec', icon: ShieldAlert, label: 'CrowdSec', daemon: 'crowdsec' as const }, { to: '/dhcp', icon: Wifi, label: 'DHCP', daemon: 'dnsmasq' as const }, { to: '/dns-filter', icon: ShieldBan, label: 'DNS Filter', daemon: 'dnsmasq' as const }, + { to: '/integrations', icon: Plug, label: 'Integrations', daemon: undefined }, ]; const advancedItems = [ @@ -171,7 +172,7 @@ export function AppSidebar() { {centralItems.map(({ to, icon: Icon, label, daemon }) => { - const active = centralStatus?.daemons?.[daemon]?.active; + const active = daemon ? centralStatus?.daemons?.[daemon]?.active : undefined; return ( diff --git a/web/src/components/IntegrationsComponent.tsx b/web/src/components/IntegrationsComponent.tsx new file mode 100644 index 0000000..0ce6746 --- /dev/null +++ b/web/src/components/IntegrationsComponent.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Key, RotateCw, Loader2, AlertCircle, CheckCircle, Eye, EyeOff } from 'lucide-react'; +import { Card } from './ui/card'; +import { Button } from './ui/button'; +import { Badge } from './ui/badge'; +import { Alert, AlertDescription } from './ui/alert'; +import { CopyButton } from './CopyButton'; +import { integrationsApi } from '../services/api/integrations'; + +export function IntegrationsComponent() { + const queryClient = useQueryClient(); + const [showToken, setShowToken] = useState(false); + const [successMessage, setSuccessMessage] = useState(null); + + const { data, isLoading, error } = useQuery({ + queryKey: ['integrations'], + queryFn: () => integrationsApi.get(), + }); + + const rotateMutation = useMutation({ + mutationFn: () => integrationsApi.rotateApiToken(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['integrations'] }); + setSuccessMessage('API token rotated. Update the token in any connected services.'); + setTimeout(() => setSuccessMessage(null), 8000); + }, + }); + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + if (error) { + return ( + + +

Error Loading Integrations

+

{(error as Error)?.message || 'An error occurred'}

+
+ ); + } + + const token = data?.apiToken?.token || ''; + const maskedToken = token ? `${token.slice(0, 6)}${'*'.repeat(Math.max(0, token.length - 6))}` : ''; + + return ( +
+
+
+ +
+
+

Integrations

+

+ Manage API tokens and service connections +

+
+
+ + {successMessage && ( + + + {successMessage} + + )} + + {rotateMutation.error && ( + + + {(rotateMutation.error as Error)?.message || 'Failed to rotate token'} + + )} + + +
+
+

API Token

+

+ Bearer token for authenticating with the Wild Central API +

+
+ + {token ? ( + <> Configured + ) : ( + <> Not Set + )} + +
+ + {token && ( + <> +
+ + {showToken ? token : maskedToken} + + + +
+ +
+

Usage

+

+ Services like Wild Cloud use this token to authenticate with Wild Central's API. + Set it as the WILD_CENTRAL_TOKEN environment variable. +

+ + WILD_CENTRAL_TOKEN={showToken ? token : maskedToken} + +
+ + )} + +
+ + {token && ( +

+ Rotating the token will invalidate the current one. Connected services will need to be updated. +

+ )} +
+
+
+ ); +} diff --git a/web/src/router/pages/IntegrationsPage.tsx b/web/src/router/pages/IntegrationsPage.tsx new file mode 100644 index 0000000..6d575a2 --- /dev/null +++ b/web/src/router/pages/IntegrationsPage.tsx @@ -0,0 +1,10 @@ +import { ErrorBoundary } from '../../components'; +import { IntegrationsComponent } from '../../components/IntegrationsComponent'; + +export function IntegrationsPage() { + return ( + + + + ); +} diff --git a/web/src/router/routes.tsx b/web/src/router/routes.tsx index 97a9093..752ebf4 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -9,6 +9,7 @@ import { CrowdSecPage } from './pages/CrowdSecPage'; import { DhcpPage } from './pages/DhcpPage'; import { DnsFilterPage } from './pages/DnsFilterPage'; import { AutheliaPage } from './pages/AutheliaPage'; +import { IntegrationsPage } from './pages/IntegrationsPage'; import { HaproxyPage, DnsmasqPage, NftablesPage, WireguardPage, CrowdsecPage as CrowdsecAdvancedPage, @@ -30,6 +31,7 @@ export const routes: RouteObject[] = [ { path: 'auth', element: }, { path: 'dhcp', element: }, { path: 'dns-filter', element: }, + { path: 'integrations', element: }, { path: 'advanced/haproxy', element: }, { path: 'advanced/dnsmasq', element: }, { path: 'advanced/nftables', element: }, diff --git a/web/src/services/api/integrations.ts b/web/src/services/api/integrations.ts new file mode 100644 index 0000000..c00caa4 --- /dev/null +++ b/web/src/services/api/integrations.ts @@ -0,0 +1,23 @@ +import { apiClient } from './client'; + +export interface IntegrationsResponse { + apiToken: { + token: string; + description: string; + }; +} + +export interface RotateTokenResponse { + token: string; + message: string; +} + +export const integrationsApi = { + async get(): Promise { + return apiClient.get('/api/v1/integrations'); + }, + + async rotateApiToken(): Promise { + return apiClient.post('/api/v1/integrations/api-token/rotate'); + }, +};