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
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.
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user