feat(radius): RADIUS-Server via FreeRADIUS (PAP/CHAP) — v1.2.93
Files-basierter RADIUS-Server (Clients + Users), managed analog DHCP/WireGuard.
- Migration 0042: radius_settings (singleton, node-lokal), radius_clients (secret_enc), radius_users (password_enc) — Secrets via secrets.Box verschlüsselt.
- internal/freeradius: Multi-File-Renderer (clients.conf + authorize) via Box.Open, Secret-Escaping (" \), Service default-off/an enabled gekoppelt. internal/services/radius + internal/handlers/radius.go: Settings + Client/User-CRUD, write-only Secret-Semantik, Validierung (IP/CIDR, name-charset), GET liefert secret_configured statt Secret.
- Firewall: udp 1812/1813 Auto-Rule bei enabled. Cluster: clients/users repliziert (hashSpec), radius_settings node-lokal.
- main.go + render.go + WithAllReloaders. Packaging: freeradius Dependency, setgid-Dir /etc/edgeguard/freeradius (Gruppe freeradius), Symlinks clients.conf+authorize, disable-on-install, sudoers.
- UI: RADIUS-Seite (Einstellungen + Clients + Benutzer) unter Sicherheit, Route/Nav/i18n de/en.
- Tests (guarded): Renderer-Inhalt + Secret-Escaping/Roundtrip + Masking. Scope v1: PAP/CHAP files-based (kein EAP/802.1X).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -26,6 +26,7 @@ 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 RADIUSPage = lazy(() => import('./pages/RADIUS'))
|
||||
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||
const DHCPPage = lazy(() => import('./pages/DHCP'))
|
||||
const NTPPage = lazy(() => import('./pages/NTP'))
|
||||
@@ -161,6 +162,7 @@ export default function App() {
|
||||
<Route path="/firewall/live" element={<FirewallLivePage />} />
|
||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||
<Route path="/radius" element={<RADIUSPage />} />
|
||||
<Route path="/dns" element={<DNSPage />} />
|
||||
<Route path="/dhcp" element={<DHCPPage />} />
|
||||
<Route path="/ntp" element={<NTPPage />} />
|
||||
|
||||
@@ -23,6 +23,7 @@ const PAGE_TITLES: Record<string, string> = {
|
||||
'/ntp': 'nav.ntp',
|
||||
'/vpn/wireguard': 'nav.wireguard',
|
||||
'/forward-proxy': 'nav.forwardProxy',
|
||||
'/radius': 'nav.radius',
|
||||
'/firewall/live': 'nav.firewallLive',
|
||||
'/firewall': 'nav.firewall',
|
||||
'/cluster': 'nav.cluster',
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
DatabaseOutlined,
|
||||
FireOutlined,
|
||||
GlobalOutlined,
|
||||
IdcardOutlined,
|
||||
NodeIndexOutlined,
|
||||
RadarChartOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
@@ -80,6 +81,7 @@ const NAV: NavSection[] = [
|
||||
{ path: '/forward-proxy', labelKey: 'nav.forwardProxy', icon: <CloudServerOutlined /> },
|
||||
{ path: '/crowdsec', labelKey: 'nav.crowdsec', icon: <RadarChartOutlined /> },
|
||||
{ path: '/waf', labelKey: 'nav.waf', icon: <SafetyCertificateOutlined /> },
|
||||
{ path: '/radius', labelKey: 'nav.radius', icon: <IdcardOutlined /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"security": "Sicherheit",
|
||||
"system": "System"
|
||||
},
|
||||
"waf": "WAF"
|
||||
"waf": "WAF",
|
||||
"radius": "RADIUS"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall",
|
||||
@@ -1855,6 +1856,39 @@
|
||||
"confirmRemove": "Collection {{name}} wirklich entfernen?"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"title": "RADIUS (FreeRADIUS)",
|
||||
"intro": "RADIUS-Authentifizierung (PAP/CHAP): NAS-Clients + Benutzer für VPN-/Switch-/AP-Login.",
|
||||
"csvHint": "Komma-getrennt; leer = alle Adressen.",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"deleted": "Gelöscht",
|
||||
"deleteFailed": "Löschen fehlgeschlagen",
|
||||
"secretSet": "Gespeichert — leer lassen, um es unverändert zu lassen.",
|
||||
"secretUnset": "Noch nichts gespeichert.",
|
||||
"tabs": { "settings": "Einstellungen", "clients": "Clients (NAS)", "users": "Benutzer" },
|
||||
"settings": {
|
||||
"enabled": "RADIUS auf dieser Node aktiv",
|
||||
"listen": "Listen-Adressen"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"ipaddr": "IP / CIDR",
|
||||
"secret": "Shared Secret",
|
||||
"description": "Beschreibung",
|
||||
"add": "Client hinzufügen",
|
||||
"edit": "Client bearbeiten",
|
||||
"deleteConfirm": "Client \"{{name}}\" wirklich löschen?"
|
||||
},
|
||||
"user": {
|
||||
"username": "Benutzername",
|
||||
"password": "Passwort",
|
||||
"papHint": "Benutzer-Passwörter werden für PAP/CHAP als Cleartext in der FreeRADIUS-Datei hinterlegt (verschlüsselt at-rest in der DB, Datei nur für root/freeradius lesbar).",
|
||||
"add": "Benutzer hinzufügen",
|
||||
"edit": "Benutzer bearbeiten",
|
||||
"deleteConfirm": "Benutzer \"{{name}}\" wirklich löschen?"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
"title": "Web Application Firewall",
|
||||
"intro": "Domänen-spezifische HTTP-Request-Inspektion via Coraza/OWASP CRS. Standard: für alle Domains deaktiviert.",
|
||||
|
||||
@@ -36,7 +36,8 @@
|
||||
"security": "Security",
|
||||
"system": "System"
|
||||
},
|
||||
"waf": "WAF"
|
||||
"waf": "WAF",
|
||||
"radius": "RADIUS"
|
||||
},
|
||||
"fw": {
|
||||
"title": "Firewall",
|
||||
@@ -1855,6 +1856,39 @@
|
||||
"confirmRemove": "Really remove collection {{name}}?"
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"title": "RADIUS (FreeRADIUS)",
|
||||
"intro": "RADIUS authentication (PAP/CHAP): NAS clients + users for VPN/switch/AP login.",
|
||||
"csvHint": "Comma-separated; empty = all addresses.",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Save failed",
|
||||
"deleted": "Deleted",
|
||||
"deleteFailed": "Delete failed",
|
||||
"secretSet": "Stored — leave empty to keep unchanged.",
|
||||
"secretUnset": "Nothing stored yet.",
|
||||
"tabs": { "settings": "Settings", "clients": "Clients (NAS)", "users": "Users" },
|
||||
"settings": {
|
||||
"enabled": "RADIUS active on this node",
|
||||
"listen": "Listen addresses"
|
||||
},
|
||||
"client": {
|
||||
"name": "Name",
|
||||
"ipaddr": "IP / CIDR",
|
||||
"secret": "Shared secret",
|
||||
"description": "Description",
|
||||
"add": "Add client",
|
||||
"edit": "Edit client",
|
||||
"deleteConfirm": "Really delete client \"{{name}}\"?"
|
||||
},
|
||||
"user": {
|
||||
"username": "Username",
|
||||
"password": "Password",
|
||||
"papHint": "User passwords are stored as cleartext in the FreeRADIUS file for PAP/CHAP (encrypted at-rest in the DB; the file is readable only by root/freeradius).",
|
||||
"add": "Add user",
|
||||
"edit": "Edit user",
|
||||
"deleteConfirm": "Really delete user \"{{name}}\"?"
|
||||
}
|
||||
},
|
||||
"waf": {
|
||||
"title": "Web Application Firewall",
|
||||
"intro": "Per-domain HTTP request inspection via Coraza/OWASP CRS. Default: off for all domains.",
|
||||
|
||||
226
management-ui/src/pages/RADIUS/index.tsx
Normal file
226
management-ui/src/pages/RADIUS/index.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Alert, Button, Card, Form, Input, Modal, Popconfirm, Space, Switch,
|
||||
Table, Tabs, Tag, Tooltip, message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { IdcardOutlined, 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 { useAuthStore } from '../../stores/auth'
|
||||
import PageHeader from '../../components/PageHeader'
|
||||
|
||||
interface RADIUSSettings { id: number; enabled: boolean; listen_addresses: string }
|
||||
interface RADIUSClient {
|
||||
id: number; name: string; ipaddr: string; active: boolean; description: string; secret_configured: boolean
|
||||
}
|
||||
interface RADIUSUser { id: number; username: string; active: boolean; password_configured: boolean }
|
||||
|
||||
export default function RADIUSPage() {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div>
|
||||
<PageHeader icon={<IdcardOutlined />} title={t('radius.title')} subtitle={t('radius.intro')} />
|
||||
<Tabs
|
||||
defaultActiveKey="settings"
|
||||
items={[
|
||||
{ key: 'settings', label: t('radius.tabs.settings'), children: <SettingsTab /> },
|
||||
{ key: 'clients', label: t('radius.tabs.clients'), children: <ClientsTab /> },
|
||||
{ key: 'users', label: t('radius.tabs.users'), children: <UsersTab /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<RADIUSSettings>()
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/settings')
|
||||
return isEnvelope(r.data) ? (r.data.data as RADIUSSettings) : null
|
||||
},
|
||||
})
|
||||
useEffect(() => { if (data) form.setFieldsValue(data) }, [data, form])
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: RADIUSSettings) => apiClient.put('/radius/settings', v),
|
||||
onSuccess: () => { msg.success(t('radius.saved')); void qc.invalidateQueries({ queryKey: ['radius', 'settings'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Form<RADIUSSettings> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.settings.enabled')} name="enabled" valuePropName="checked">
|
||||
<Switch disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('radius.settings.listen')} name="listen_addresses" extra={t('radius.csvHint')}>
|
||||
<Input placeholder="0.0.0.0" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending} disabled={isViewer}>{t('common.save')}</Button>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface ClientForm { name: string; ipaddr: string; secret?: string; active: boolean; description: string }
|
||||
|
||||
function ClientsTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<ClientForm>()
|
||||
const [editing, setEditing] = useState<RADIUSClient | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'clients'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/clients')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { clients?: RADIUSClient[] }).clients ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: ClientForm) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
if (!v.secret) delete body.secret // leer = unverändert
|
||||
return editing ? apiClient.put(`/radius/clients/${editing.id}`, body) : apiClient.post('/radius/clients', body)
|
||||
},
|
||||
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/radius/clients/${id}`),
|
||||
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'clients'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<RADIUSClient> = [
|
||||
{ title: t('radius.client.name'), dataIndex: 'name' },
|
||||
{ title: t('radius.client.ipaddr'), dataIndex: 'ipaddr' },
|
||||
{ title: t('radius.client.secret'), dataIndex: 'secret_configured', render: (v: boolean) => (v ? <Tag color="green">✓</Tag> : <Tag>—</Tag>) },
|
||||
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 200,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ name: r.name, ipaddr: r.ipaddr, active: r.active, description: r.description, secret: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
|
||||
{!isViewer && <Popconfirm title={t('radius.client.deleteConfirm', { name: r.name })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
|
||||
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<ClientForm>); setOpen(true) }}>
|
||||
{t('radius.client.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<RADIUSClient> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
|
||||
<Modal title={editing ? t('radius.client.edit') : t('radius.client.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
|
||||
<Form<ClientForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.client.name')} name="name" rules={[{ required: true }]}><Input placeholder="switch-core" /></Form.Item>
|
||||
<Form.Item label={t('radius.client.ipaddr')} name="ipaddr" rules={[{ required: true }]}><Input placeholder="10.0.0.0/24" /></Form.Item>
|
||||
<Form.Item label={t('radius.client.secret')} name="secret" extra={editing ? (editing.secret_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder={editing?.secret_configured ? '••••••••' : ''} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('radius.client.description')} name="description"><Input /></Form.Item>
|
||||
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface UserForm { username: string; password?: string; active: boolean }
|
||||
|
||||
function UsersTab() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [form] = Form.useForm<UserForm>()
|
||||
const [editing, setEditing] = useState<RADIUSUser | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ['radius', 'users'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/radius/users')
|
||||
return isEnvelope(r.data) ? ((r.data.data as { users?: RADIUSUser[] }).users ?? []) : []
|
||||
},
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (v: UserForm) => {
|
||||
const body: Record<string, unknown> = { ...v }
|
||||
if (!v.password) delete body.password
|
||||
return editing ? apiClient.put(`/radius/users/${editing.id}`, body) : apiClient.post('/radius/users', body)
|
||||
},
|
||||
onSuccess: () => { msg.success(t('radius.saved')); setOpen(false); setEditing(null); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
const del = useMutation({
|
||||
mutationFn: async (id: number) => apiClient.delete(`/radius/users/${id}`),
|
||||
onSuccess: () => { msg.success(t('radius.deleted')); void qc.invalidateQueries({ queryKey: ['radius', 'users'] }) },
|
||||
onError: (e: Error) => msg.error(t('radius.deleteFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const cols: ColumnsType<RADIUSUser> = [
|
||||
{ title: t('radius.user.username'), dataIndex: 'username' },
|
||||
{ title: t('radius.user.password'), dataIndex: 'password_configured', render: (v: boolean) => (v ? <Tag color="green">✓</Tag> : <Tag>—</Tag>) },
|
||||
{ title: t('common.status'), dataIndex: 'active', render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? t('common.active') : t('common.inactive')}</Tag> },
|
||||
{
|
||||
title: t('common.actions'), key: 'a', width: 200,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
{!isViewer && <Button size="small" onClick={() => { setEditing(r); form.setFieldsValue({ username: r.username, active: r.active, password: '' }); setOpen(true) }}>{t('common.edit')}</Button>}
|
||||
{!isViewer && <Popconfirm title={t('radius.user.deleteConfirm', { name: r.username })} onConfirm={() => del.mutate(r.id)}><Button size="small" danger>{t('common.delete')}</Button></Popconfirm>}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<Card size="small">
|
||||
{msgCtx}
|
||||
<Alert type="info" showIcon className="mb-16" message={t('radius.user.papHint')} />
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" icon={<PlusOutlined />} disabled={isViewer}
|
||||
onClick={() => { setEditing(null); form.resetFields(); form.setFieldsValue({ active: true } as Partial<UserForm>); setOpen(true) }}>
|
||||
{t('radius.user.add')}
|
||||
</Button>
|
||||
</Space>
|
||||
<Table<RADIUSUser> rowKey="id" size="small" columns={cols} dataSource={data ?? []} pagination={false} />
|
||||
<Modal title={editing ? t('radius.user.edit') : t('radius.user.add')} open={open} onCancel={() => setOpen(false)} onOk={() => form.submit()} confirmLoading={save.isPending} destroyOnClose>
|
||||
<Form<UserForm> form={form} layout="vertical" onFinish={(v) => save.mutate(v)}>
|
||||
<Form.Item label={t('radius.user.username')} name="username" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item label={t('radius.user.password')} name="password" extra={editing ? (editing.password_configured ? t('radius.secretSet') : t('radius.secretUnset')) : undefined} rules={editing ? [] : [{ required: true }]}>
|
||||
<Input.Password autoComplete="new-password" placeholder={editing?.password_configured ? '••••••••' : ''} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('common.active')} name="active" valuePropName="checked"><Switch /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user