Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2abc81f3 |
@@ -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