feat: Squid Forward-Proxy — vollständig (Renderer + Handler + UI)
Stub raus, vollständig implementiert: * internal/services/forwardproxy: CRUD-Repo gegen forward_proxy_acls (priority desc, action allow|deny). * internal/handlers/forwardproxy.go: REST /api/v1/forward-proxy/acls mit Validation (acl_type-Whitelist verhindert Squid-Reload-Crash bei Tippfehlern). Auto-Reload nach jeder Mutation. * internal/squid/squid.cfg.tpl + squid.go: Renderer schreibt /etc/edgeguard/squid/squid.conf, atomic + Symlink von /etc/squid/squid.conf (Squid liest Distro-Pfad — gleicher Pattern-Fix wie wg-quick). cache_dir 100MB, cache_mem 64MB, http_port 3128. Default-Policy: nur localnet (10/8, 172.16/12, 192.168/16) — verhindert Open-Relay, falls Operator keine ACLs anlegt. * main.go: forwardproxy-Repo + squid-Reloader instanziiert + Handler registriert. * render.go: squid.New() bekommt Pool (war () vorher, Stub-Signatur). * postinst sudoers: edgeguard darf systemctl reload squid.service. * Frontend /forward-proxy: PageHeader + DataTable + ACL-Modal mit acl_type-Dropdown (13 Squid-Vokabular-Typen), action-Select, Priority. Sidebar-Eintrag unter Security. * i18n DE/EN für fwd.* Block + nav.forwardProxy. Verified end-to-end: ACL-Insert via SQL, render → squid reload → curl -x http://127.0.0.1:3128 http://example.com/ → 200. Version 1.0.26. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "edgeguard-management-ui",
|
||||
"private": true,
|
||||
"version": "1.0.25",
|
||||
"version": "1.0.26",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -21,6 +21,7 @@ const IPAddressesPage = lazy(() => import('./pages/IPAddresses'))
|
||||
const SSLPage = lazy(() => import('./pages/SSL'))
|
||||
const FirewallPage = lazy(() => import('./pages/Firewall'))
|
||||
const WireguardPage = lazy(() => import('./pages/Wireguard'))
|
||||
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||
|
||||
@@ -101,6 +102,7 @@ export default function App() {
|
||||
<Route path="/ssl" element={<SSLPage />} />
|
||||
<Route path="/firewall" element={<FirewallPage />} />
|
||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||
<Route path="/cluster" element={<ClusterPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NavLink } from 'react-router-dom'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
ApartmentOutlined,
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
@@ -57,8 +58,9 @@ const NAV: NavSection[] = [
|
||||
{
|
||||
labelKey: 'nav.section.security',
|
||||
items: [
|
||||
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
|
||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||
{ path: '/firewall', labelKey: 'nav.firewall', icon: <FireOutlined /> },
|
||||
{ path: '/vpn/wireguard', labelKey: 'nav.wireguard', icon: <ThunderboltOutlined /> },
|
||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -70,7 +72,7 @@ const NAV: NavSection[] = [
|
||||
},
|
||||
]
|
||||
|
||||
const VERSION = '1.0.25'
|
||||
const VERSION = '1.0.26'
|
||||
|
||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"ssl": "SSL-Zertifikate",
|
||||
"vpn": "VPN",
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward-Proxy",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Einstellungen",
|
||||
@@ -398,6 +399,25 @@
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"fwd": {
|
||||
"title": "Forward-Proxy (Squid)",
|
||||
"intro": "Squid-basierter Forward-Proxy auf :3128. ACLs werden top-down nach Priority ausgewertet — first-match wins. Wenn keine Regel passt, gewinnt der Default: nur localnet (10/8, 172.16/12, 192.168/16) darf raus.",
|
||||
"helpTitle": "Tipp zur ACL-Reihenfolge",
|
||||
"helpBody": "Höhere Priority = wird zuerst geprüft. Beispiel: 'deny .badsite.com' (priority 200) vor 'allow .com' (priority 100). Werte können Listen sein (mehrere Zeilen), Regex je nach acl_type.",
|
||||
"name": "Name",
|
||||
"nameExtra": "Squid-konformer Bezeichner — Kleinbuchstaben + _, kein Leerzeichen.",
|
||||
"aclType": "Typ",
|
||||
"aclTypeExtra": "Was Squid prüft (Quelle, Domain, Port, …).",
|
||||
"value": "Wert",
|
||||
"valueExtra": "Format hängt vom Typ ab — IPs/CIDRs für src/dst, Domain mit führendem . für dstdomain (.example.com matcht auch sub.example.com), Regex für *_regex-Typen.",
|
||||
"action": "Aktion",
|
||||
"priority": "Priority",
|
||||
"priorityExtra": "Höher = wird zuerst geprüft.",
|
||||
"comment": "Kommentar",
|
||||
"add": "ACL hinzufügen",
|
||||
"edit": "ACL bearbeiten",
|
||||
"deleteConfirm": "ACL {{name}} wirklich löschen?"
|
||||
},
|
||||
"common": {
|
||||
"yes": "Ja",
|
||||
"no": "Nein",
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"ssl": "SSL certificates",
|
||||
"vpn": "VPN",
|
||||
"wireguard": "WireGuard",
|
||||
"forwardProxy": "Forward proxy",
|
||||
"firewall": "Firewall",
|
||||
"cluster": "Cluster",
|
||||
"settings": "Settings",
|
||||
@@ -398,6 +399,25 @@
|
||||
"wg": "WireGuard"
|
||||
}
|
||||
},
|
||||
"fwd": {
|
||||
"title": "Forward proxy (Squid)",
|
||||
"intro": "Squid-based forward proxy on :3128. ACLs are evaluated top-down by priority — first match wins. If no rule matches, the default permits only localnet (10/8, 172.16/12, 192.168/16).",
|
||||
"helpTitle": "ACL ordering tip",
|
||||
"helpBody": "Higher priority = evaluated first. Example: 'deny .badsite.com' (priority 200) before 'allow .com' (priority 100). Values can be lists (multiple lines), regex depending on acl_type.",
|
||||
"name": "Name",
|
||||
"nameExtra": "Squid-conformant identifier — lowercase + _, no spaces.",
|
||||
"aclType": "Type",
|
||||
"aclTypeExtra": "What Squid matches (source, domain, port, …).",
|
||||
"value": "Value",
|
||||
"valueExtra": "Format depends on type — IPs/CIDRs for src/dst, domain with leading dot for dstdomain (.example.com also matches sub.example.com), regex for *_regex types.",
|
||||
"action": "Action",
|
||||
"priority": "Priority",
|
||||
"priorityExtra": "Higher = evaluated first.",
|
||||
"comment": "Comment",
|
||||
"add": "Add ACL",
|
||||
"edit": "Edit ACL",
|
||||
"deleteConfirm": "Really delete ACL {{name}}?"
|
||||
},
|
||||
"common": {
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
|
||||
198
management-ui/src/pages/ForwardProxy/index.tsx
Normal file
198
management-ui/src/pages/ForwardProxy/index.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Form, Input, InputNumber, Modal, Select, Switch, Tag, Typography, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { CloudServerOutlined, PlusOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import apiClient, { isEnvelope } from '../../api/client'
|
||||
import DataTable from '../../components/DataTable'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
import ActionButtons from '../../components/ActionButtons'
|
||||
import StatusDot from '../../components/StatusDot'
|
||||
|
||||
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 FormValues {
|
||||
name: string
|
||||
acl_type: string
|
||||
value: string
|
||||
action: 'allow' | 'deny'
|
||||
priority: number
|
||||
active: boolean
|
||||
comment?: string
|
||||
}
|
||||
|
||||
// ACL-Typen aus Squid's Vokabular — dieselbe Whitelist wie der
|
||||
// Backend-Validator. Mehr Typen kann Squid (browser, time-of-day,
|
||||
// arp, ...) — die spielen wir später dazu.
|
||||
const ACL_TYPES = [
|
||||
{ value: 'src', label: 'src — Quell-IP/CIDR' },
|
||||
{ value: 'dst', label: 'dst — Ziel-IP/CIDR' },
|
||||
{ value: 'dstdomain', label: 'dstdomain — Ziel-Domain (exact)' },
|
||||
{ value: 'srcdomain', label: 'srcdomain — Quell-Domain (rDNS)' },
|
||||
{ value: 'port', label: 'port — Ziel-Port' },
|
||||
{ value: 'proto', label: 'proto — http/https/ftp/...' },
|
||||
{ value: 'method', label: 'method — GET/POST/CONNECT/...' },
|
||||
{ value: 'time', label: 'time — Wochentag/Zeit' },
|
||||
{ value: 'url_regex', label: 'url_regex — kompletter URL-Match' },
|
||||
{ value: 'urlpath_regex', label: 'urlpath_regex — Path-Teil' },
|
||||
{ value: 'dstdom_regex', label: 'dstdom_regex — Domain-Regex' },
|
||||
{ value: 'srcdom_regex', label: 'srcdom_regex — Quell-Domain-Regex' },
|
||||
{ value: 'browser', label: 'browser — User-Agent-Regex' },
|
||||
]
|
||||
|
||||
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 ?? []
|
||||
}
|
||||
|
||||
export default function ForwardProxyPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const { data, isLoading } = useQuery({ queryKey: ['fwd-proxy', 'acls'], queryFn: listACLs })
|
||||
|
||||
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 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',
|
||||
render: (v: boolean) => <StatusDot active={v} /> },
|
||||
{
|
||||
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 })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
icon={<CloudServerOutlined />}
|
||||
title={t('fwd.title')}
|
||||
subtitle={t('fwd.intro')}
|
||||
/>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-12"
|
||||
message={t('fwd.helpTitle')}
|
||||
description={t('fwd.helpBody')}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
dataSource={data ?? []}
|
||||
columns={cols}
|
||||
extraActions={
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||
setCreating(true); form.resetFields()
|
||||
form.setFieldsValue({ priority: 100, active: true, action: 'allow', acl_type: 'dstdomain' })
|
||||
}}>
|
||||
{t('fwd.add')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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: 'allow — Zugriff erlauben' },
|
||||
{ value: 'deny', label: 'deny — Zugriff blockieren' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('fwd.aclType')} name="acl_type" rules={[{ required: true }]}
|
||||
extra={t('fwd.aclTypeExtra')}>
|
||||
<Select options={ACL_TYPES} 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=".example.com oder 10.10.20.0/24 oder 443" />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user