From fa2abc81f35c24fd586ae92a7f7335e577a2dcaf Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 8 Jul 2026 17:17:34 +0700 Subject: [PATCH] feat(firewall): add install context query and API endpoint for firewall client setup Introduced a new API endpoint for retrieving the install context of the firewall client, which includes the bundle seed, configuration status, and suggested control plane URL. Updated the frontend to utilize this new endpoint, enhancing the user experience by dynamically displaying relevant information. Additionally, added type definitions for the install context and integrated it into the existing firewall management flow. --- apps/web/src/queries/firewall.ts | 18 ++++++++- apps/web/src/routes/_auth/firewall.tsx | 31 +++++++++++++-- apps/web/src/types/api.ts | 7 ++++ docs/openapi.yaml | 33 ++++++++++++++++ internal/httpapi/routes_firewall.go | 34 ++++++++++++++++ internal/httpapi/routes_firewall_test.go | 50 ++++++++++++++++++++++++ 6 files changed, 169 insertions(+), 4 deletions(-) diff --git a/apps/web/src/queries/firewall.ts b/apps/web/src/queries/firewall.ts index fce8dd9..9508a7f 100644 --- a/apps/web/src/queries/firewall.ts +++ b/apps/web/src/queries/firewall.ts @@ -1,14 +1,30 @@ import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' import { apiJSON } from '@/lib/api-client' -import type { FirewallClient, FirewallClientsResponse, FirewallRule, FirewallRulesResponse } from '@/types/api' +import type { + FirewallClient, + FirewallClientsResponse, + FirewallInstallContext, + FirewallRule, + FirewallRulesResponse, +} from '@/types/api' export const firewallKeys = { all: ['firewall'] as const, clients: () => [...firewallKeys.all, 'clients'] as const, + installContext: () => [...firewallKeys.all, 'install-context'] as const, rules: (scope: string, clientId?: string) => [...firewallKeys.all, 'rules', scope, clientId ?? ''] as const, } +export function firewallInstallContextQueryOptions() { + return queryOptions({ + queryKey: firewallKeys.installContext(), + queryFn: () => apiJSON('/v1/firewall/install-context'), + staleTime: 60_000, + retry: false, + }) +} + export function firewallClientsQueryOptions() { return queryOptions({ queryKey: firewallKeys.clients(), diff --git a/apps/web/src/routes/_auth/firewall.tsx b/apps/web/src/routes/_auth/firewall.tsx index 51cc91a..5498179 100644 --- a/apps/web/src/routes/_auth/firewall.tsx +++ b/apps/web/src/routes/_auth/firewall.tsx @@ -1,7 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { useQuery } from '@tanstack/react-query' import { Copy, Info, RefreshCw, Shield } from 'lucide-react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' @@ -23,6 +23,7 @@ import { PageHeader } from '@/components/page-header' import { StatusBadge } from '@/components/status-badge' import { firewallClientsQueryOptions, + firewallInstallContextQueryOptions, firewallRulesQueryOptions, useApproveFirewallClient, useCreateFirewallRule, @@ -35,17 +36,29 @@ export const Route = createFileRoute('/_auth/firewall')({ }) function FirewallPage() { + const installCtxQ = useQuery(firewallInstallContextQueryOptions()) const clientsQ = useQuery(firewallClientsQueryOptions()) const rulesQ = useQuery(firewallRulesQueryOptions('tenant')) const approve = useApproveFirewallClient() const createRule = useCreateFirewallRule() const deleteRule = useDeleteFirewallRule() + const installCtx = installCtxQ.data + const [clientName, setClientName] = useState('web-01') const [cpUrl, setCpUrl] = useState(() => typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com', ) const [seed, setSeed] = useState('') + + useEffect(() => { + if (installCtx?.suggested_cp_url) { + setCpUrl(installCtx.suggested_cp_url) + } + if (installCtx?.bundle_seed) { + setSeed(installCtx.bundle_seed) + } + }, [installCtx?.bundle_seed, installCtx?.suggested_cp_url]) const [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block') const [ruleComment, setRuleComment] = useState('') @@ -64,7 +77,11 @@ function FirewallPage() { async function copyInstall() { if (!seed.trim()) { - toast.error('Укажите bundle seed') + toast.error( + installCtx?.bundle_seed_configured === false + ? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX' + : 'Bundle seed недоступен (нужна роль operator)', + ) return } try { @@ -128,10 +145,18 @@ function FirewallPage() { setSeed(e.target.value)} + className="font-mono text-xs" /> +

+ {installCtxQ.isLoading + ? 'Загрузка из control plane…' + : installCtx?.bundle_seed_configured + ? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)' + : 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'} +

{installCmd}
diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index f73f47c..d7ac9a2 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -373,3 +373,10 @@ export type FirewallRule = { } export type FirewallRulesResponse = { items: FirewallRule[] } + +export type FirewallInstallContext = { + bundle_seed: string + bundle_seed_configured: boolean + suggested_cp_url: string + install_sh_url: string +} diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3c27d81..eea8aa1 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1608,6 +1608,22 @@ components: client_version: type: string + FirewallInstallContext: + type: object + description: Контекст для one-liner установки firewall-клиента (только operator). + properties: + bundle_seed: + type: string + description: Значение EVOBGP_BUNDLE_SEED_HEX на control plane. + bundle_seed_configured: + type: boolean + suggested_cp_url: + type: string + format: uri + install_sh_url: + type: string + format: uri + FirewallRule: type: object properties: @@ -4364,6 +4380,23 @@ paths: default: $ref: "#/components/responses/DefaultProblem" + /v1/firewall/install-context: + get: + tags: [Firewall] + summary: Install context for firewall one-liner (operator) + operationId: getFirewallInstallContext + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/FirewallInstallContext" + "403": + $ref: "#/components/responses/Forbidden" + default: + $ref: "#/components/responses/DefaultProblem" + /v1/firewall/enroll: post: tags: [Firewall] diff --git a/internal/httpapi/routes_firewall.go b/internal/httpapi/routes_firewall.go index 85e8e1e..ec267b5 100644 --- a/internal/httpapi/routes_firewall.go +++ b/internal/httpapi/routes_firewall.go @@ -20,6 +20,7 @@ import ( ) func (s *Server) registerFirewallRoutes(m *http.ServeMux) { + m.HandleFunc("GET /firewall/install-context", s.handleFirewallInstallContext) m.HandleFunc("GET /firewall/clients", s.handleListFirewallClients) m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient) m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview) @@ -39,6 +40,39 @@ func (s *Server) registerFirewallRoutes(m *http.ServeMux) { m.HandleFunc("POST /firewall/heartbeat", s.handleFirewallHeartbeat) } +func (s *Server) handleFirewallInstallContext(w http.ResponseWriter, r *http.Request) { + a, ok := authFromContext(r.Context()) + if !ok || !s.requireAtLeast(w, a, "operator") { + return + } + seed := strings.TrimSpace(s.bundleSeedHex) + writeJSON(w, http.StatusOK, map[string]any{ + "bundle_seed": seed, + "bundle_seed_configured": seed != "", + "suggested_cp_url": requestBaseURL(r), + "install_sh_url": requestBaseURL(r) + "/v1/firewall/install.sh", + }) +} + +func requestBaseURL(r *http.Request) string { + scheme := "https" + if r.TLS == nil { + if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); xf != "" { + scheme = strings.ToLower(strings.Split(xf, ",")[0]) + } else if strings.EqualFold(r.URL.Scheme, "http") { + scheme = "http" + } + } + host := strings.TrimSpace(r.Host) + if xf := strings.TrimSpace(r.Header.Get("X-Forwarded-Host")); xf != "" { + host = strings.TrimSpace(strings.Split(xf, ",")[0]) + } + if host == "" { + return "" + } + return scheme + "://" + host +} + func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required") diff --git a/internal/httpapi/routes_firewall_test.go b/internal/httpapi/routes_firewall_test.go index 985eee9..0427edc 100644 --- a/internal/httpapi/routes_firewall_test.go +++ b/internal/httpapi/routes_firewall_test.go @@ -119,6 +119,56 @@ func TestFirewallEnrollBadSeed(t *testing.T) { } } +func TestFirewallInstallContext(t *testing.T) { + srv, err := New(Options{SeedDemo: true, BundleSeedHex: testBundleSeed}) + if err != nil { + t.Fatal(err) + } + defer srv.Close() + tenant, _, _, _, _ := srv.Store().DemoIDs() + mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator,vwkey|"+tenant+"|viewer") + + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + client := ts.Client() + + reqOp, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil) + reqOp.Header.Set("Authorization", "Bearer opkey") + respOp, err := client.Do(reqOp) + if err != nil { + t.Fatal(err) + } + defer func() { _ = respOp.Body.Close() }() + if respOp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(respOp.Body) + t.Fatalf("operator install-context status=%d body=%s", respOp.StatusCode, b) + } + var ctx map[string]any + if err := json.NewDecoder(respOp.Body).Decode(&ctx); err != nil { + t.Fatal(err) + } + if seed, _ := ctx["bundle_seed"].(string); seed != testBundleSeed { + t.Fatalf("bundle_seed=%q want %q", seed, testBundleSeed) + } + if configured, _ := ctx["bundle_seed_configured"].(bool); !configured { + t.Fatal("bundle_seed_configured want true") + } + if url, _ := ctx["install_sh_url"].(string); !strings.HasSuffix(url, "/v1/firewall/install.sh") { + t.Fatalf("install_sh_url=%q", url) + } + + reqVw, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/firewall/install-context", nil) + reqVw.Header.Set("Authorization", "Bearer vwkey") + respVw, err := client.Do(reqVw) + if err != nil { + t.Fatal(err) + } + defer func() { _ = respVw.Body.Close() }() + if respVw.StatusCode != http.StatusForbidden { + t.Fatalf("viewer install-context want 403 got %d", respVw.StatusCode) + } +} + func TestFirewallTokenHashMatchesAuthkey(t *testing.T) { tok := "evobgp_fw_sample" h := authkey.HashToken(tok)