Compare commits

...
1 Commits
Author SHA1 Message Date
Denozordec fa2abc81f3 feat(firewall): add install context query and API endpoint for firewall client setup
CI / changes (push) Successful in 13s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 1m1s
CI / go (push) Successful in 1m16s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 4m4s
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.
2026-07-08 17:17:34 +07:00
6 changed files with 169 additions and 4 deletions
+17 -1
View File
@@ -1,14 +1,30 @@
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query' import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiJSON } from '@/lib/api-client' 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 = { export const firewallKeys = {
all: ['firewall'] as const, all: ['firewall'] as const,
clients: () => [...firewallKeys.all, 'clients'] as const, clients: () => [...firewallKeys.all, 'clients'] as const,
installContext: () => [...firewallKeys.all, 'install-context'] as const,
rules: (scope: string, clientId?: string) => rules: (scope: string, clientId?: string) =>
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const, [...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
} }
export function firewallInstallContextQueryOptions() {
return queryOptions<FirewallInstallContext>({
queryKey: firewallKeys.installContext(),
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
staleTime: 60_000,
retry: false,
})
}
export function firewallClientsQueryOptions() { export function firewallClientsQueryOptions() {
return queryOptions<FirewallClientsResponse>({ return queryOptions<FirewallClientsResponse>({
queryKey: firewallKeys.clients(), queryKey: firewallKeys.clients(),
+28 -3
View File
@@ -1,7 +1,7 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Copy, Info, RefreshCw, Shield } from 'lucide-react' import { Copy, Info, RefreshCw, Shield } from 'lucide-react'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert' 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 { StatusBadge } from '@/components/status-badge'
import { import {
firewallClientsQueryOptions, firewallClientsQueryOptions,
firewallInstallContextQueryOptions,
firewallRulesQueryOptions, firewallRulesQueryOptions,
useApproveFirewallClient, useApproveFirewallClient,
useCreateFirewallRule, useCreateFirewallRule,
@@ -35,17 +36,29 @@ export const Route = createFileRoute('/_auth/firewall')({
}) })
function FirewallPage() { function FirewallPage() {
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
const clientsQ = useQuery(firewallClientsQueryOptions()) const clientsQ = useQuery(firewallClientsQueryOptions())
const rulesQ = useQuery(firewallRulesQueryOptions('tenant')) const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
const approve = useApproveFirewallClient() const approve = useApproveFirewallClient()
const createRule = useCreateFirewallRule() const createRule = useCreateFirewallRule()
const deleteRule = useDeleteFirewallRule() const deleteRule = useDeleteFirewallRule()
const installCtx = installCtxQ.data
const [clientName, setClientName] = useState('web-01') const [clientName, setClientName] = useState('web-01')
const [cpUrl, setCpUrl] = useState(() => const [cpUrl, setCpUrl] = useState(() =>
typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com', typeof window !== 'undefined' ? window.location.origin : 'https://api.example.com',
) )
const [seed, setSeed] = useState('') 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 [ruleAction, setRuleAction] = useState<'block' | 'accept'>('block')
const [ruleComment, setRuleComment] = useState('') const [ruleComment, setRuleComment] = useState('')
@@ -64,7 +77,11 @@ function FirewallPage() {
async function copyInstall() { async function copyInstall() {
if (!seed.trim()) { if (!seed.trim()) {
toast.error('Укажите bundle seed') toast.error(
installCtx?.bundle_seed_configured === false
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
: 'Bundle seed недоступен (нужна роль operator)',
)
return return
} }
try { try {
@@ -128,10 +145,18 @@ function FirewallPage() {
<Input <Input
id="fw-seed" id="fw-seed"
type="password" type="password"
readOnly
placeholder="EVOBGP_BUNDLE_SEED_HEX" placeholder="EVOBGP_BUNDLE_SEED_HEX"
value={seed} value={seed}
onChange={(e) => setSeed(e.target.value)} className="font-mono text-xs"
/> />
<p className="text-muted-foreground text-xs">
{installCtxQ.isLoading
? 'Загрузка из control plane…'
: installCtx?.bundle_seed_configured
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — enroll невозможен'}
</p>
</div> </div>
</div> </div>
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre> <pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
+7
View File
@@ -373,3 +373,10 @@ export type FirewallRule = {
} }
export type FirewallRulesResponse = { items: FirewallRule[] } export type FirewallRulesResponse = { items: FirewallRule[] }
export type FirewallInstallContext = {
bundle_seed: string
bundle_seed_configured: boolean
suggested_cp_url: string
install_sh_url: string
}
+33
View File
@@ -1608,6 +1608,22 @@ components:
client_version: client_version:
type: string 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: FirewallRule:
type: object type: object
properties: properties:
@@ -4364,6 +4380,23 @@ paths:
default: default:
$ref: "#/components/responses/DefaultProblem" $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: /v1/firewall/enroll:
post: post:
tags: [Firewall] tags: [Firewall]
+34
View File
@@ -20,6 +20,7 @@ import (
) )
func (s *Server) registerFirewallRoutes(m *http.ServeMux) { 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", s.handleListFirewallClients)
m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient) m.HandleFunc("GET /firewall/clients/{id}", s.handleGetFirewallClient)
m.HandleFunc("GET /firewall/clients/{id}/preview", s.handleFirewallClientPreview) 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) 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) { func (s *Server) handleFirewallEnrollPublic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required") writeProblem(w, http.StatusMethodNotAllowed, "Method Not Allowed", "POST required")
+50
View File
@@ -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) { func TestFirewallTokenHashMatchesAuthkey(t *testing.T) {
tok := "evobgp_fw_sample" tok := "evobgp_fw_sample"
h := authkey.HashToken(tok) h := authkey.HashToken(tok)