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:
Debian
2026-05-11 00:27:05 +02:00
parent e379162a7f
commit 72269f5b7c
16 changed files with 677 additions and 15 deletions

View 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>
)
}