Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa2abc81f3 |
@@ -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(),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user