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)