- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung - keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload) - confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix) - TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login - Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator - fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen - VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034) - Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032) - unbound-control: edgeguard in unbound-Gruppe via postinst Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
486 lines
18 KiB
TypeScript
486 lines
18 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import {
|
|
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, Divider,
|
|
} from 'antd'
|
|
import type { ColumnsType } from 'antd/es/table'
|
|
import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
|
|
import apiClient, { isEnvelope } from '../../api/client'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
import DataTable from '../../components/DataTable'
|
|
import EmptyState from '../../components/EmptyState'
|
|
import PageHeader from '../../components/PageHeader'
|
|
import ActionButtons from '../../components/ActionButtons'
|
|
|
|
const { Text } = Typography
|
|
|
|
interface ACL {
|
|
id: number
|
|
name: string
|
|
acl_type: string
|
|
value: string
|
|
action: 'allow' | 'deny'
|
|
priority: number
|
|
active: boolean
|
|
comment?: string | null
|
|
created_at: string
|
|
updated_at: string
|
|
}
|
|
|
|
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
|
|
|
|
interface ProxySettings {
|
|
id: number
|
|
listen_addresses: string
|
|
listen_port: number
|
|
cache_mem_mb: number
|
|
cache_dir_mb: number
|
|
max_obj_size_mb: number
|
|
connect_timeout: number
|
|
read_timeout: number
|
|
request_timeout: number
|
|
}
|
|
|
|
interface FormValues {
|
|
name: string
|
|
acl_type: string
|
|
value: string
|
|
action: 'allow' | 'deny'
|
|
priority: number
|
|
active: boolean
|
|
comment?: string
|
|
}
|
|
|
|
const ACL_TYPE_KEYS = [
|
|
'src', 'dst', 'dstdomain', 'srcdomain', 'port', 'proto', 'method',
|
|
'time', 'url_regex', 'urlpath_regex', 'dstdom_regex', 'srcdom_regex', 'browser',
|
|
] as const
|
|
|
|
async function listACLs(): Promise<ACL[]> {
|
|
const r = await apiClient.get('/forward-proxy/acls')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { acls?: ACL[] }).acls ?? []
|
|
}
|
|
|
|
interface SquidStats {
|
|
client_requests: number
|
|
cache_hits: number
|
|
cache_hit_pct: number
|
|
client_errors: number
|
|
bytes_in: number
|
|
bytes_out: number
|
|
server_requests: number
|
|
server_errors: number
|
|
}
|
|
|
|
function fmtBytes(n: number): string {
|
|
if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB'
|
|
if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB'
|
|
if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
|
|
return n + ' B'
|
|
}
|
|
|
|
export default function ForwardProxyPage() {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
const { data, isLoading } = useQuery({ queryKey: ['fwd-proxy', 'acls'], queryFn: listACLs })
|
|
|
|
const { data: services } = useQuery({
|
|
queryKey: ['system', 'services'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/system/services')
|
|
return isEnvelope(r.data) ? (r.data.data as { services: ServiceStatus[] }).services : []
|
|
},
|
|
refetchInterval: 30_000,
|
|
})
|
|
const squid = services?.find(s => s.unit === 'squid.service' || s.unit === 'squid')
|
|
|
|
const { data: statsData, refetch: refetchStats } = useQuery({
|
|
queryKey: ['fwd-proxy', 'stats'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/forward-proxy/stats')
|
|
if (!isEnvelope(r.data)) return null
|
|
const d = r.data.data as { stats?: SquidStats; error?: string }
|
|
if (d.error) return null
|
|
return d.stats ?? null
|
|
},
|
|
refetchInterval: 30_000,
|
|
})
|
|
|
|
const { data: settings, isLoading: settingsLoading } = useQuery({
|
|
queryKey: ['fwd-proxy', 'settings'],
|
|
queryFn: async () => {
|
|
const r = await apiClient.get('/forward-proxy/settings')
|
|
if (!isEnvelope(r.data)) return null
|
|
return r.data.data as ProxySettings
|
|
},
|
|
})
|
|
|
|
const [settingsForm] = Form.useForm<ProxySettings>()
|
|
const saveSettings = useMutation({
|
|
mutationFn: async (v: ProxySettings) => {
|
|
await apiClient.put('/forward-proxy/settings', v)
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'settings'] })
|
|
},
|
|
onError: () => message.error(t('fwd.settings.saveFailed')),
|
|
})
|
|
|
|
const [editing, setEditing] = useState<ACL | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [form] = Form.useForm<FormValues>()
|
|
|
|
const upsert = useMutation({
|
|
mutationFn: async (v: FormValues) => {
|
|
if (editing) return (await apiClient.put(`/forward-proxy/acls/${editing.id}`, v)).data
|
|
return (await apiClient.post('/forward-proxy/acls', v)).data
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('common.save'))
|
|
setEditing(null); setCreating(false); form.resetFields()
|
|
void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const del = useMutation({
|
|
mutationFn: async (id: number) => { await apiClient.delete(`/forward-proxy/acls/${id}`) },
|
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const quickToggle = useMutation({
|
|
mutationFn: async ({ id, row, checked }: { id: number; row: ACL; checked: boolean }) => {
|
|
await apiClient.put(`/forward-proxy/acls/${id}`, { ...row, active: checked })
|
|
},
|
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const sortedACLs = useMemo(
|
|
() => [...(data ?? [])].sort((a, b) => a.priority - b.priority),
|
|
[data],
|
|
)
|
|
const swapACLs = useMutation({
|
|
mutationFn: async ({ a, b }: { a: ACL; b: ACL }) => {
|
|
const stripMeta = ({ id: _id, created_at: _ca, updated_at: _ua, ...r }: ACL) => r
|
|
await apiClient.put(`/forward-proxy/acls/${a.id}`, { ...stripMeta(a), priority: b.priority })
|
|
await apiClient.put(`/forward-proxy/acls/${b.id}`, { ...stripMeta(b), priority: a.priority })
|
|
},
|
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'acls'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const cols: ColumnsType<ACL> = [
|
|
{ title: t('fwd.priority'), dataIndex: 'priority', key: 'priority', width: 90 },
|
|
{ title: t('fwd.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
|
{ title: t('fwd.action'), dataIndex: 'action', key: 'action',
|
|
render: (a: string) => <Tag color={a === 'allow' ? 'green' : 'red'}>{a.toUpperCase()}</Tag> },
|
|
{ title: t('fwd.aclType'), dataIndex: 'acl_type', key: 'acl_type',
|
|
render: (s: string) => <code>{s}</code> },
|
|
{ title: t('fwd.value'), dataIndex: 'value', key: 'value',
|
|
render: (s: string) => <Text code style={{ fontSize: 12 }}>{s}</Text> },
|
|
{ title: t('fwd.comment'), dataIndex: 'comment', key: 'comment',
|
|
render: (v?: string | null) => v ?? '—' },
|
|
{
|
|
title: t('common.active'), dataIndex: 'active', key: 'active', width: 80,
|
|
render: (v: boolean, row: ACL) => (
|
|
<Switch
|
|
size="small"
|
|
checked={v}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
|
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: '', key: 'move', width: 64,
|
|
render: (_, row) => {
|
|
const idx = sortedACLs.findIndex(r => r.id === row.id)
|
|
const swapping = swapACLs.isPending
|
|
return (
|
|
<Space size={2}>
|
|
<Tooltip title={t('fw.rule.moveUp')}>
|
|
<Button size="small" icon={<ArrowUpOutlined />}
|
|
disabled={isViewer || idx <= 0 || swapping}
|
|
onClick={() => swapACLs.mutate({ a: row, b: sortedACLs[idx - 1] })} />
|
|
</Tooltip>
|
|
<Tooltip title={t('fw.rule.moveDown')}>
|
|
<Button size="small" icon={<ArrowDownOutlined />}
|
|
disabled={isViewer || idx >= sortedACLs.length - 1 || swapping}
|
|
onClick={() => swapACLs.mutate({ a: row, b: sortedACLs[idx + 1] })} />
|
|
</Tooltip>
|
|
</Space>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: t('common.actions'), key: 'actions',
|
|
render: (_, row) => (
|
|
<ActionButtons
|
|
onEdit={() => {
|
|
setEditing(row)
|
|
form.setFieldsValue({
|
|
name: row.name, acl_type: row.acl_type, value: row.value,
|
|
action: row.action, priority: row.priority, active: row.active,
|
|
comment: row.comment ?? undefined,
|
|
})
|
|
}}
|
|
onDelete={() => del.mutate(row.id)}
|
|
deleteConfirm={t('fwd.deleteConfirm', { name: row.name })}
|
|
/>
|
|
),
|
|
},
|
|
]
|
|
|
|
const openCreate = () => {
|
|
setCreating(true); form.resetFields()
|
|
form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' })
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<PageHeader
|
|
icon={<CloudServerOutlined />}
|
|
title={t('fwd.title')}
|
|
subtitle={t('fwd.intro')}
|
|
extra={squid && (
|
|
<Tag
|
|
icon={squid.active ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
|
color={squid.active ? 'green' : 'red'}
|
|
>
|
|
<Space size={4}>
|
|
<span>squid</span>
|
|
<span style={{ fontWeight: 400, opacity: 0.85 }}>{squid.state}</span>
|
|
</Space>
|
|
</Tag>
|
|
)}
|
|
/>
|
|
|
|
<Alert
|
|
type="info"
|
|
showIcon
|
|
className="mb-12"
|
|
message={t('fwd.helpTitle')}
|
|
description={t('fwd.helpBody')}
|
|
/>
|
|
|
|
{statsData && (
|
|
<Card
|
|
size="small"
|
|
className="mb-12"
|
|
title={<><BarChartOutlined /> {t('fwd.statsCard.title')}</>}
|
|
extra={
|
|
<Space size={4}>
|
|
<Typography.Text type="secondary" style={{ fontSize: 11 }}>{t('fwd.statsCard.sinceRestart')}</Typography.Text>
|
|
<Tooltip title={t('common.refresh')}>
|
|
<Button size="small" icon={<ReloadOutlined />} onClick={() => { void refetchStats() }} />
|
|
</Tooltip>
|
|
</Space>
|
|
}
|
|
>
|
|
<Row gutter={[16, 8]}>
|
|
<Col xs={12} sm={6} md={4}>
|
|
<Statistic title={t('fwd.statsCard.clientRequests')} value={statsData.client_requests} />
|
|
</Col>
|
|
<Col xs={12} sm={6} md={4}>
|
|
<Statistic title={t('fwd.statsCard.cacheHits')} value={statsData.cache_hits} />
|
|
</Col>
|
|
<Col xs={12} sm={6} md={4}>
|
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>{t('fwd.statsCard.cacheHitPct')}</Typography.Text>
|
|
<Progress
|
|
percent={Math.round(statsData.cache_hit_pct)}
|
|
size="small"
|
|
strokeColor={statsData.cache_hit_pct >= 50 ? '#16a34a' : statsData.cache_hit_pct >= 20 ? '#ca8a04' : '#dc2626'}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={6} md={4}>
|
|
<Statistic title={t('fwd.statsCard.serverRequests')} value={statsData.server_requests} />
|
|
</Col>
|
|
<Col xs={12} sm={6} md={4}>
|
|
<Statistic title={t('fwd.statsCard.bytesIn')} value={fmtBytes(statsData.bytes_in)} />
|
|
</Col>
|
|
<Col xs={12} sm={6} md={4}>
|
|
<Statistic title={t('fwd.statsCard.bytesOut')} value={fmtBytes(statsData.bytes_out)} />
|
|
</Col>
|
|
</Row>
|
|
{statsData.client_errors > 0 && (
|
|
<Typography.Text type="danger" style={{ fontSize: 12 }}>
|
|
{t('fwd.statsCard.clientErrors')}: {statsData.client_errors}
|
|
{statsData.server_errors > 0 && ` · ${t('fwd.statsCard.serverErrors')}: ${statsData.server_errors}`}
|
|
</Typography.Text>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
<Card
|
|
size="small"
|
|
className="mb-12"
|
|
title={t('fwd.settings.title')}
|
|
loading={settingsLoading}
|
|
>
|
|
<Form
|
|
form={settingsForm}
|
|
layout="vertical"
|
|
initialValues={settings ?? {
|
|
listen_addresses: '', listen_port: 3128,
|
|
cache_mem_mb: 64, cache_dir_mb: 100, max_obj_size_mb: 4,
|
|
connect_timeout: 60, read_timeout: 300, request_timeout: 300,
|
|
}}
|
|
key={settings?.id ?? 'loading'}
|
|
onFinish={(v) => saveSettings.mutate(v)}
|
|
>
|
|
<Row gutter={16}>
|
|
<Col xs={24} md={16}>
|
|
<Form.Item
|
|
label={t('fwd.settings.listenAddresses')}
|
|
name="listen_addresses"
|
|
extra={t('fwd.settings.listenAddressesExtra')}
|
|
>
|
|
<Input placeholder="10.0.5.1, 10.0.20.1" disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col xs={24} md={8}>
|
|
<Form.Item
|
|
label={t('fwd.settings.listenPort')}
|
|
name="listen_port"
|
|
extra={t('fwd.settings.listenPortExtra')}
|
|
>
|
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Divider plain>{t('fwd.settings.cacheSection')}</Divider>
|
|
<Row gutter={16}>
|
|
<Col xs={24} sm={8}>
|
|
<Form.Item label={t('fwd.settings.cacheMemMB')} name="cache_mem_mb" extra={t('fwd.settings.cacheMemMBExtra')}>
|
|
<InputNumber min={16} max={8192} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col xs={24} sm={8}>
|
|
<Form.Item label={t('fwd.settings.cacheDirMB')} name="cache_dir_mb" extra={t('fwd.settings.cacheDirMBExtra')}>
|
|
<InputNumber min={100} max={102400} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col xs={24} sm={8}>
|
|
<Form.Item label={t('fwd.settings.maxObjSizeMB')} name="max_obj_size_mb" extra={t('fwd.settings.maxObjSizeMBExtra')}>
|
|
<InputNumber min={1} max={1024} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Divider plain>{t('fwd.settings.timeoutSection')}</Divider>
|
|
<Row gutter={16}>
|
|
<Col xs={24} sm={8}>
|
|
<Form.Item label={t('fwd.settings.connectTimeout')} name="connect_timeout" extra={t('fwd.settings.connectTimeoutExtra')}>
|
|
<InputNumber min={5} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col xs={24} sm={8}>
|
|
<Form.Item label={t('fwd.settings.readTimeout')} name="read_timeout" extra={t('fwd.settings.readTimeoutExtra')}>
|
|
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
<Col xs={24} sm={8}>
|
|
<Form.Item label={t('fwd.settings.requestTimeout')} name="request_timeout" extra={t('fwd.settings.requestTimeoutExtra')}>
|
|
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
|
|
</Form.Item>
|
|
</Col>
|
|
</Row>
|
|
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button
|
|
type="primary"
|
|
htmlType="submit"
|
|
loading={saveSettings.isPending}
|
|
disabled={isViewer}
|
|
>
|
|
{t('common.save')}
|
|
</Button>
|
|
</Tooltip>
|
|
</Form>
|
|
</Card>
|
|
|
|
<Divider />
|
|
|
|
<DataTable
|
|
rowKey="id"
|
|
loading={isLoading}
|
|
dataSource={data ?? []}
|
|
columns={cols}
|
|
extraActions={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
|
{t('fwd.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
emptyContent={
|
|
<EmptyState
|
|
icon={<CloudServerOutlined />}
|
|
title={t('fwd.emptyTitle')}
|
|
description={t('fwd.emptyDesc')}
|
|
action={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer} onClick={openCreate}>
|
|
{t('fwd.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<Modal
|
|
title={editing ? t('fwd.edit') : t('fwd.add')}
|
|
open={editing !== null || creating}
|
|
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
|
|
onOk={() => { void form.submit() }}
|
|
confirmLoading={upsert.isPending}
|
|
width={620}
|
|
destroyOnClose
|
|
>
|
|
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
|
|
<Form.Item label={t('fwd.name')} name="name" rules={[{ required: true }]}
|
|
extra={t('fwd.nameExtra')}>
|
|
<Input placeholder="allow_internal_lan" />
|
|
</Form.Item>
|
|
<Form.Item label={t('fwd.action')} name="action" rules={[{ required: true }]}>
|
|
<Select options={[
|
|
{ value: 'allow', label: t('fwd.actions.allow') },
|
|
{ value: 'deny', label: t('fwd.actions.deny') },
|
|
]} />
|
|
</Form.Item>
|
|
<Form.Item label={t('fwd.aclType')} name="acl_type" rules={[{ required: true }]}
|
|
extra={t('fwd.aclTypeExtra')}>
|
|
<Select
|
|
options={ACL_TYPE_KEYS.map(k => ({ value: k, label: t(`fwd.aclTypes.${k}`) }))}
|
|
showSearch
|
|
optionFilterProp="value"
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item label={t('fwd.value')} name="value" rules={[{ required: true }]}
|
|
extra={t('fwd.valueExtra')}>
|
|
<Input.TextArea rows={2} placeholder={t('fwd.valuePlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item label={t('fwd.priority')} name="priority" rules={[{ required: true }]}
|
|
extra={t('fwd.priorityExtra')}>
|
|
<InputNumber min={0} max={1000} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item label={t('fwd.comment')} name="comment">
|
|
<Input.TextArea rows={2} />
|
|
</Form.Item>
|
|
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</div>
|
|
)
|
|
}
|