366 lines
17 KiB
TypeScript
366 lines
17 KiB
TypeScript
import { useState, useEffect, Dispatch, SetStateAction } from 'react'
|
|
import { useParams, Link } from 'react-router-dom'
|
|
import Layout from '../components/Layout'
|
|
import DataTable, { Column } from '../components/DataTable'
|
|
import EntityForm, { FieldDef } from '../components/EntityForm'
|
|
import GenerateModal from '../components/GenerateModal'
|
|
import Box from '@mui/material/Box'
|
|
import Button from '@mui/material/Button'
|
|
import Tabs from '@mui/material/Tabs'
|
|
import Tab from '@mui/material/Tab'
|
|
import Typography from '@mui/material/Typography'
|
|
import Breadcrumbs from '@mui/material/Breadcrumbs'
|
|
import IconButton from '@mui/material/IconButton'
|
|
import InputAdornment from '@mui/material/InputAdornment'
|
|
import OutlinedInput from '@mui/material/OutlinedInput'
|
|
import FormControl from '@mui/material/FormControl'
|
|
import InputLabel from '@mui/material/InputLabel'
|
|
import Tooltip from '@mui/material/Tooltip'
|
|
import AddIcon from '@mui/icons-material/Add'
|
|
import BuildIcon from '@mui/icons-material/Build'
|
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
|
|
import RefreshIcon from '@mui/icons-material/Refresh'
|
|
import { zonesApi, interfacesApi, policiesApi, rulesApi, snatApi, hostsApi, paramsApi, configsApi } from '../api'
|
|
|
|
// ---- Types ----
|
|
interface Zone { id: number; name: string; type: string; options: string }
|
|
interface Iface { id: number; name: string; zone_id: number; options: string }
|
|
interface Policy { id: number; src_zone_id: number; dst_zone_id: number; policy: string; log_level: string; comment: string; position: number }
|
|
interface Rule { id: number; action: string; src_zone_id: number | null; dst_zone_id: number | null; src_ip: string; dst_ip: string; proto: string; dport: string; sport: string; origdest: string; rate_limit: string; user_group: string; mark: string; connlimit: string; time: string; headers: string; switch_name: string; helper: string; comment: string; position: number }
|
|
interface Snat { id: number; source_network: string; out_interface: string; to_address: string; proto: string; port: string; ipsec: string; mark: string; user_group: string; switch_name: string; origdest: string; probability: string; comment: string }
|
|
interface Host { id: number; zone_id: number; interface: string; subnet: string; options: string }
|
|
interface Param { id: number; name: string; value: string }
|
|
|
|
type AnyEntity = { id: number } & Record<string, unknown>
|
|
|
|
export default function ConfigDetail() {
|
|
const { id } = useParams<{ id: string }>()
|
|
const configId = Number(id)
|
|
|
|
const [configName, setConfigName] = useState('')
|
|
const [tab, setTab] = useState(0)
|
|
const [zones, setZones] = useState<Zone[]>([])
|
|
const [interfaces, setInterfaces] = useState<Iface[]>([])
|
|
const [policies, setPolicies] = useState<Policy[]>([])
|
|
const [rules, setRules] = useState<Rule[]>([])
|
|
const [snat, setSnat] = useState<Snat[]>([])
|
|
const [hosts, setHosts] = useState<Host[]>([])
|
|
const [paramsList, setParamsList] = useState<Param[]>([])
|
|
const [formOpen, setFormOpen] = useState(false)
|
|
const [editing, setEditing] = useState<AnyEntity | null>(null)
|
|
const [generateOpen, setGenerateOpen] = useState(false)
|
|
const [downloadToken, setDownloadToken] = useState('')
|
|
|
|
useEffect(() => {
|
|
configsApi.get(configId).then((r) => {
|
|
setConfigName(r.data.name)
|
|
setDownloadToken(r.data.download_token)
|
|
})
|
|
zonesApi.list(configId).then((r) => setZones(r.data))
|
|
interfacesApi.list(configId).then((r) => setInterfaces(r.data))
|
|
policiesApi.list(configId).then((r) => setPolicies(r.data))
|
|
rulesApi.list(configId).then((r) => setRules(r.data))
|
|
snatApi.list(configId).then((r) => setSnat(r.data))
|
|
hostsApi.list(configId).then((r) => setHosts(r.data))
|
|
paramsApi.list(configId).then((r) => setParamsList(r.data))
|
|
}, [configId])
|
|
|
|
const zoneOptions = zones.map((z) => ({ value: z.id, label: z.name }))
|
|
|
|
// ---- Tab configs ----
|
|
const tabConfig = [
|
|
{
|
|
label: 'Zones',
|
|
rows: zones as unknown as AnyEntity[],
|
|
setRows: setZones as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
|
|
api: zonesApi,
|
|
columns: [
|
|
{ key: 'name' as const, label: 'Name' },
|
|
{ key: 'type' as const, label: 'Type' },
|
|
{ key: 'options' as const, label: 'Options' },
|
|
] as Column<AnyEntity>[],
|
|
fields: [
|
|
{ name: 'name', label: 'Name', required: true },
|
|
{ name: 'type', label: 'Type', required: true, type: 'select' as const, options: [{ value: 'ipv4', label: 'ipv4' }, { value: 'ipv6', label: 'ipv6' }, { value: 'firewall', label: 'firewall' }] },
|
|
{ name: 'options', label: 'Options' },
|
|
] as FieldDef[],
|
|
},
|
|
{
|
|
label: 'Interfaces',
|
|
rows: interfaces as unknown as AnyEntity[],
|
|
setRows: setInterfaces as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
|
|
api: interfacesApi,
|
|
columns: [
|
|
{ key: 'name' as const, label: 'Interface' },
|
|
{
|
|
key: 'zone_id' as const,
|
|
label: 'Zone',
|
|
render: (r: AnyEntity) => r['zone_id'] == null ? '-' : (zones.find((z) => z.id === r['zone_id'])?.name ?? String(r['zone_id'])),
|
|
},
|
|
{ key: 'broadcast' as const, label: 'Broadcast' },
|
|
{ key: 'options' as const, label: 'Options' },
|
|
] as Column<AnyEntity>[],
|
|
fields: [
|
|
{ name: 'name', label: 'Interface Name', required: true },
|
|
{ name: 'zone_id', label: 'Zone', type: 'select' as const, options: [{ value: '', label: '- (no zone)' }, ...zoneOptions] },
|
|
{ name: 'broadcast', label: 'Broadcast' },
|
|
{ name: 'options', label: 'Options' },
|
|
] as FieldDef[],
|
|
},
|
|
{
|
|
label: 'Policies',
|
|
rows: policies as unknown as AnyEntity[],
|
|
setRows: setPolicies as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
|
|
api: policiesApi,
|
|
columns: [
|
|
{
|
|
key: 'src_zone_id' as const,
|
|
label: 'Source',
|
|
render: (r: AnyEntity) => r['src_zone_id'] == null ? 'all' : (zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id'])),
|
|
},
|
|
{
|
|
key: 'dst_zone_id' as const,
|
|
label: 'Destination',
|
|
render: (r: AnyEntity) => r['dst_zone_id'] == null ? 'all' : (zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id'])),
|
|
},
|
|
{ key: 'policy' as const, label: 'Policy' },
|
|
{ key: 'log_level' as const, label: 'Log Level' },
|
|
{ key: 'limit_burst' as const, label: 'Limit:Burst' },
|
|
{ key: 'connlimit_mask' as const, label: 'ConnLimit:Mask' },
|
|
{ key: 'position' as const, label: 'Position' },
|
|
] as Column<AnyEntity>[],
|
|
fields: [
|
|
{ name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
|
|
{ name: 'dst_zone_id', label: 'Destination Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
|
|
{ name: 'policy', label: 'Policy', required: true, type: 'select' as const, options: [{ value: 'ACCEPT', label: 'ACCEPT' }, { value: 'DROP', label: 'DROP' }, { value: 'REJECT', label: 'REJECT' }, { value: 'CONTINUE', label: 'CONTINUE' }] },
|
|
{ name: 'log_level', label: 'Log Level' },
|
|
{ name: 'limit_burst', label: 'Limit:Burst', placeholder: 'e.g. 10/sec:20' },
|
|
{ name: 'connlimit_mask', label: 'ConnLimit:Mask', placeholder: 'e.g. 10:24' },
|
|
{ name: 'comment', label: 'Comment' },
|
|
{ name: 'position', label: 'Position', type: 'number' as const },
|
|
] as FieldDef[],
|
|
},
|
|
{
|
|
label: 'Rules',
|
|
rows: rules as unknown as AnyEntity[],
|
|
setRows: setRules as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
|
|
api: rulesApi,
|
|
columns: [
|
|
{ key: 'action' as const, label: 'Action' },
|
|
{
|
|
key: 'src_zone_id' as const,
|
|
label: 'Source',
|
|
render: (r: AnyEntity) => r['src_zone_id'] ? (zones.find((z) => z.id === r['src_zone_id'])?.name ?? String(r['src_zone_id'])) : 'all',
|
|
},
|
|
{
|
|
key: 'dst_zone_id' as const,
|
|
label: 'Destination',
|
|
render: (r: AnyEntity) => r['dst_zone_id'] ? (zones.find((z) => z.id === r['dst_zone_id'])?.name ?? String(r['dst_zone_id'])) : 'all',
|
|
},
|
|
{ key: 'proto' as const, label: 'Proto' },
|
|
{ key: 'dport' as const, label: 'DPort' },
|
|
{ key: 'origdest' as const, label: 'OrigDest' },
|
|
{ key: 'position' as const, label: 'Position' },
|
|
] as Column<AnyEntity>[],
|
|
fields: [
|
|
{ name: 'action', label: 'Action', required: true },
|
|
{ name: 'src_zone_id', label: 'Source Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
|
|
{ name: 'dst_zone_id', label: 'Dest Zone', type: 'select' as const, options: [{ value: '', label: 'all' }, ...zoneOptions] },
|
|
{ name: 'src_ip', label: 'Source IP/CIDR' },
|
|
{ name: 'dst_ip', label: 'Dest IP/CIDR' },
|
|
{ name: 'proto', label: 'Protocol', placeholder: 'e.g. tcp, udp, icmp' },
|
|
{ name: 'dport', label: 'Dest Port(s)' },
|
|
{ name: 'sport', label: 'Source Port(s)' },
|
|
{ name: 'origdest', label: 'Original Dest', placeholder: 'e.g. 192.168.1.1' },
|
|
{ name: 'rate_limit', label: 'Rate Limit', placeholder: 'e.g. 10/sec:20' },
|
|
{ name: 'user_group', label: 'User/Group', placeholder: 'e.g. joe:wheel' },
|
|
{ name: 'mark', label: 'Mark', placeholder: 'e.g. 0x100/0xff0' },
|
|
{ name: 'connlimit', label: 'ConnLimit', placeholder: 'e.g. 10:24' },
|
|
{ name: 'time', label: 'Time', placeholder: 'e.g. timestart=09:00×top=17:00' },
|
|
{ name: 'headers', label: 'Headers (IPv6)', placeholder: 'e.g. auth,esp' },
|
|
{ name: 'switch_name', label: 'Switch', placeholder: 'e.g. vpn_enabled' },
|
|
{ name: 'helper', label: 'Helper', type: 'select' as const, options: [
|
|
{ value: '', label: '(none)' },
|
|
{ value: 'amanda', label: 'amanda' },
|
|
{ value: 'ftp', label: 'ftp' },
|
|
{ value: 'irc', label: 'irc' },
|
|
{ value: 'netbios-ns', label: 'netbios-ns' },
|
|
{ value: 'pptp', label: 'pptp' },
|
|
{ value: 'Q.931', label: 'Q.931' },
|
|
{ value: 'RAS', label: 'RAS' },
|
|
{ value: 'sane', label: 'sane' },
|
|
{ value: 'sip', label: 'sip' },
|
|
{ value: 'snmp', label: 'snmp' },
|
|
{ value: 'tftp', label: 'tftp' },
|
|
]},
|
|
{ name: 'comment', label: 'Comment' },
|
|
{ name: 'position', label: 'Position', type: 'number' as const },
|
|
] as FieldDef[],
|
|
},
|
|
{
|
|
label: 'SNAT',
|
|
rows: snat as unknown as AnyEntity[],
|
|
setRows: setSnat as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
|
|
api: snatApi,
|
|
columns: [
|
|
{ key: 'out_interface' as const, label: 'Out Interface' },
|
|
{ key: 'source_network' as const, label: 'Source Network' },
|
|
{ key: 'to_address' as const, label: 'To Address' },
|
|
{ key: 'proto' as const, label: 'Proto' },
|
|
{ key: 'probability' as const, label: 'Probability' },
|
|
] as Column<AnyEntity>[],
|
|
fields: [
|
|
{ name: 'out_interface', label: 'Out Interface', required: true },
|
|
{ name: 'source_network', label: 'Source Network', required: true },
|
|
{ name: 'to_address', label: 'To Address (blank = MASQUERADE)', placeholder: 'e.g. 1.2.3.4' },
|
|
{ name: 'proto', label: 'Protocol', placeholder: 'e.g. tcp, udp' },
|
|
{ name: 'port', label: 'Port', placeholder: 'e.g. 80, 1024:65535' },
|
|
{ name: 'ipsec', label: 'IPsec', placeholder: 'e.g. mode=tunnel' },
|
|
{ name: 'mark', label: 'Mark', placeholder: 'e.g. 0x100/0xff0' },
|
|
{ name: 'user_group', label: 'User/Group', placeholder: 'e.g. joe:wheel' },
|
|
{ name: 'switch_name', label: 'Switch', placeholder: 'e.g. vpn_enabled' },
|
|
{ name: 'origdest', label: 'Orig Dest', placeholder: 'e.g. 1.2.3.4' },
|
|
{ name: 'probability', label: 'Probability', placeholder: 'e.g. 0.25' },
|
|
{ name: 'comment', label: 'Comment' },
|
|
] as FieldDef[],
|
|
},
|
|
{
|
|
label: 'Hosts',
|
|
rows: hosts as unknown as AnyEntity[],
|
|
setRows: setHosts as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
|
|
api: hostsApi,
|
|
columns: [
|
|
{ key: 'zone_id' as const, label: 'Zone' },
|
|
{ key: 'interface' as const, label: 'Interface' },
|
|
{ key: 'subnet' as const, label: 'Subnet' },
|
|
{ key: 'options' as const, label: 'Options' },
|
|
] as Column<AnyEntity>[],
|
|
fields: [
|
|
{ name: 'zone_id', label: 'Zone', type: 'select' as const, options: zoneOptions, required: true },
|
|
{ name: 'interface', label: 'Interface', required: true },
|
|
{ name: 'subnet', label: 'Subnet', required: true },
|
|
{ name: 'options', label: 'Options' },
|
|
] as FieldDef[],
|
|
},
|
|
{
|
|
label: 'Params',
|
|
rows: paramsList as unknown as AnyEntity[],
|
|
setRows: setParamsList as unknown as Dispatch<SetStateAction<AnyEntity[]>>,
|
|
api: paramsApi,
|
|
columns: [
|
|
{ key: 'name' as const, label: 'Name' },
|
|
{ key: 'value' as const, label: 'Value' },
|
|
] as Column<AnyEntity>[],
|
|
fields: [
|
|
{ name: 'name', label: 'Name', required: true },
|
|
{ name: 'value', label: 'Value', required: true },
|
|
] as FieldDef[],
|
|
},
|
|
]
|
|
|
|
const current = tabConfig[tab]
|
|
|
|
const handleSubmit = async (values: Record<string, unknown>) => {
|
|
if (editing) {
|
|
await current.api.update(configId, editing.id, values)
|
|
} else {
|
|
await current.api.create(configId, values)
|
|
}
|
|
const res = await current.api.list(configId)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
current.setRows(res.data as any)
|
|
setFormOpen(false)
|
|
setEditing(null)
|
|
}
|
|
|
|
const handleDelete = async (row: AnyEntity) => {
|
|
if (!confirm('Delete this entry?')) return
|
|
await current.api.delete(configId, row.id)
|
|
const res = await current.api.list(configId)
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
current.setRows(res.data as any)
|
|
}
|
|
|
|
const handleRegenerate = async () => {
|
|
if (!confirm('Regenerate the download token? The old token will stop working.')) return
|
|
const res = await configsApi.regenerateToken(configId)
|
|
setDownloadToken(res.data.download_token)
|
|
}
|
|
|
|
return (
|
|
<Layout title={configName || 'Config Detail'}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
|
|
<Breadcrumbs>
|
|
<Link to="/configs" style={{ color: 'inherit', textDecoration: 'none' }}>
|
|
<Typography variant="body2" color="text.secondary">Configurations</Typography>
|
|
</Link>
|
|
<Typography variant="body2">{configName}</Typography>
|
|
</Breadcrumbs>
|
|
<Button variant="contained" startIcon={<BuildIcon />} onClick={() => setGenerateOpen(true)}>
|
|
Generate Config
|
|
</Button>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
|
<FormControl size="small" sx={{ flex: 1 }}>
|
|
<InputLabel>Download Token</InputLabel>
|
|
<OutlinedInput
|
|
label="Download Token"
|
|
value={downloadToken}
|
|
endAdornment={
|
|
<InputAdornment position="end">
|
|
<Tooltip title="Copy token">
|
|
<IconButton onClick={() => navigator.clipboard.writeText(downloadToken)} edge="end">
|
|
<ContentCopyIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</InputAdornment>
|
|
}
|
|
inputProps={{ readOnly: true, style: { fontFamily: 'monospace', fontSize: 12 } }}
|
|
/>
|
|
</FormControl>
|
|
<Tooltip title="Regenerate token">
|
|
<IconButton onClick={handleRegenerate} color="warning">
|
|
<RefreshIcon />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</Box>
|
|
|
|
<Box sx={{ bgcolor: 'white', borderRadius: 2, border: '1px solid #e2e8f0', overflow: 'hidden' }}>
|
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', px: 2 }}>
|
|
{tabConfig.map((tc) => <Tab key={tc.label} label={tc.label} />)}
|
|
</Tabs>
|
|
<Box sx={{ p: 3 }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
|
|
<Button size="small" variant="outlined" startIcon={<AddIcon />} onClick={() => { setEditing(null); setFormOpen(true) }}>
|
|
Add {current.label.replace('/NAT', '')}
|
|
</Button>
|
|
</Box>
|
|
<DataTable
|
|
columns={current.columns}
|
|
rows={current.rows}
|
|
onEdit={(row) => { setEditing(row); setFormOpen(true) }}
|
|
onDelete={handleDelete}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
|
|
<EntityForm
|
|
open={formOpen}
|
|
title={`${editing ? 'Edit' : 'Add'} ${current.label}`}
|
|
fields={current.fields}
|
|
initialValues={editing ?? undefined}
|
|
onClose={() => { setFormOpen(false); setEditing(null) }}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
|
|
<GenerateModal
|
|
open={generateOpen}
|
|
configId={configId}
|
|
configName={configName}
|
|
onClose={() => setGenerateOpen(false)}
|
|
/>
|
|
</Layout>
|
|
)
|
|
}
|