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 { 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<FirewallInstallContext>({
queryKey: firewallKeys.installContext(),
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
staleTime: 60_000,
retry: false,
})
}
export function firewallClientsQueryOptions() {
return queryOptions<FirewallClientsResponse>({
queryKey: firewallKeys.clients(),
+28 -3
View File
@@ -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() {
<Input
id="fw-seed"
type="password"
readOnly
placeholder="EVOBGP_BUNDLE_SEED_HEX"
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>
<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 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:
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]
+34
View File
@@ -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")
+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) {
tok := "evobgp_fw_sample"
h := authkey.HashToken(tok)