feat: NTP-Server (Chrony) — vollständig

Stub raus, vollständige Implementierung analog Unbound/Squid:

* Migration 0015: ntp_settings (single-row mit listen_addresses,
  allow_acl, serve_clients, makestep, rtcsync) + ntp_pools (kind
  pool|server, address, iburst/prefer, minpoll/maxpoll). Default
  4 deutsche pool.ntp.org-Server seeded.
* Models DNSSettings/NTPPool, services/ntp Repo, handlers/ntp.go
  REST /api/v1/ntp/{settings,pools} mit Auto-Restart nach Mutation.
* internal/chrony/chrony.cfg.tpl + chrony.go: Renderer schreibt
  /etc/chrony/conf.d/edgeguard.conf direkt (analog unbound — distro
  chrony.conf included conf.d automatisch). Listen-bind nur wenn
  serve_clients=true; sonst port 0 (= Client-only).
* main.go: ntpRepo + chronyReloader injiziert.
* render.go: chrony als sechste generator.
* postinst:
  - chrony als hard Depends im control file.
  - Conf-Datei /etc/chrony/conf.d/edgeguard.conf wird als
    edgeguard:edgeguard 0644 angelegt.
  - Sudoers für systemctl reload + restart chrony.
* Auto-FW-Rule-Generator: udp/123 wenn serve_clients=true und
  listen_addresses non-loopback enthält.
* Frontend /ntp: PageHeader + Quellen-Tab + Settings-Tab. Listen-
  Addresses als Multi-Select aus Kernel-IPs (analog DNS).
* Sidebar-Eintrag unter Network.
* i18n DE/EN für ntp.* Block.

chrony.service hat kein 'reload' — Renderer ruft RestartService auf.

Verified: 4 default-pool-server connected (chronyc sources zeigt
sie nach erstem render).

Version 1.0.40.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 06:58:54 +02:00
parent 2556a93b34
commit e4d83d226e
20 changed files with 1005 additions and 8 deletions

View File

@@ -0,0 +1,297 @@
import { useState } from 'react'
import {
Alert, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ClockCircleOutlined, DatabaseOutlined, PlusOutlined, SettingOutlined } 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 Pool {
id: number
kind: 'pool' | 'server'
address: string
iburst: boolean
prefer: boolean
minpoll?: number | null
maxpoll?: number | null
active: boolean
description?: string | null
}
interface Settings {
listen_addresses: string
allow_acl: string
serve_clients: boolean
makestep_secs: number
makestep_limit: number
rtcsync: boolean
leapsectz?: string | null
}
interface SettingsForm extends Omit<Settings, 'listen_addresses'> {
listen_addresses: string[]
}
interface SystemIface {
ifname: string
addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }>
}
async function listPools(): Promise<Pool[]> {
const r = await apiClient.get('/ntp/pools')
if (!isEnvelope(r.data)) return []
return (r.data.data as { pools?: Pool[] }).pools ?? []
}
async function getSettings(): Promise<Settings | null> {
const r = await apiClient.get('/ntp/settings')
return isEnvelope(r.data) ? (r.data.data as Settings) : null
}
async function listSystemInterfaces(): Promise<SystemIface[]> {
const r = await apiClient.get('/system/interfaces')
if (!isEnvelope(r.data)) return []
return (r.data.data as { interfaces?: SystemIface[] }).interfaces ?? []
}
export default function NTPPage() {
const { t } = useTranslation()
return (
<div>
<PageHeader
icon={<ClockCircleOutlined />}
title={t('ntp.title')}
subtitle={t('ntp.intro')}
/>
<Tabs
defaultActiveKey="pools"
items={[
{ key: 'pools', label: <span><DatabaseOutlined /> {t('ntp.tabs.pools')}</span>, children: <PoolsTab /> },
{ key: 'settings', label: <span><SettingOutlined /> {t('ntp.tabs.settings')}</span>, children: <SettingsTab /> },
]}
/>
</div>
)
}
function PoolsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['ntp', 'pools'], queryFn: listPools })
const [editing, setEditing] = useState<Pool | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<Pool>()
const upsert = useMutation({
mutationFn: async (v: Pool) => {
if (editing) return (await apiClient.put(`/ntp/pools/${editing.id}`, v)).data
return (await apiClient.post('/ntp/pools', v)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/ntp/pools/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['ntp', 'pools'] }) },
onError: (e: Error) => message.error(e.message),
})
const cols: ColumnsType<Pool> = [
{ title: t('ntp.pool.kind'), dataIndex: 'kind', key: 'kind',
render: (s: string) => <Tag color={s === 'pool' ? 'blue' : 'purple'}>{s}</Tag> },
{ title: t('ntp.pool.address'), dataIndex: 'address', key: 'address',
render: (s: string) => <code>{s}</code> },
{ title: t('ntp.pool.options'), key: 'options',
render: (_, row) => (
<Space size={4}>
{row.iburst && <Tag>iburst</Tag>}
{row.prefer && <Tag color="gold">prefer</Tag>}
{row.minpoll != null && <Text type="secondary">minpoll {row.minpoll}</Text>}
{row.maxpoll != null && <Text type="secondary">maxpoll {row.maxpoll}</Text>}
</Space>
) },
{ title: t('ntp.pool.description'), dataIndex: 'description', key: 'description',
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(row)
}}
onDelete={() => del.mutate(row.id)}
deleteConfirm={t('ntp.pool.deleteConfirm', { addr: row.address })}
/>
),
},
]
return (
<>
<DataTable
rowKey="id"
loading={isLoading}
dataSource={data ?? []}
columns={cols}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ kind: 'pool', iburst: true, prefer: false, active: true } as Pool)
}}>
{t('ntp.pool.add')}
</Button>
}
/>
<Modal
title={editing ? t('ntp.pool.edit') : t('ntp.pool.add')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={580}
destroyOnClose
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Form.Item label={t('ntp.pool.kind')} name="kind" rules={[{ required: true }]}>
<Select options={[
{ value: 'pool', label: t('ntp.pool.kindPool') },
{ value: 'server', label: t('ntp.pool.kindServer') },
]} />
</Form.Item>
<Form.Item label={t('ntp.pool.address')} name="address" rules={[{ required: true }]}
extra={t('ntp.pool.addressExtra')}>
<Input placeholder="0.de.pool.ntp.org oder 10.0.0.10" />
</Form.Item>
<Space>
<Form.Item label={t('ntp.pool.iburst')} name="iburst" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item label={t('ntp.pool.prefer')} name="prefer" valuePropName="checked">
<Switch />
</Form.Item>
</Space>
<Space>
<Form.Item label={t('ntp.pool.minpoll')} name="minpoll">
<InputNumber min={0} max={17} style={{ width: 100 }} />
</Form.Item>
<Form.Item label={t('ntp.pool.maxpoll')} name="maxpoll">
<InputNumber min={0} max={17} style={{ width: 100 }} />
</Form.Item>
</Space>
<Form.Item label={t('ntp.pool.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</>
)
}
function SettingsTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['ntp', 'settings'], queryFn: getSettings })
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces })
const [form] = Form.useForm<SettingsForm>()
const ipOptions: { value: string; label: string }[] = [
{ value: '0.0.0.0', label: '0.0.0.0 — alle IPv4-Interfaces' },
{ value: '::', label: ':: — alle IPv6-Interfaces' },
{ value: '127.0.0.1', label: '127.0.0.1 — Loopback IPv4' },
{ value: '::1', label: '::1 — Loopback IPv6' },
]
for (const i of sys ?? []) {
if (i.ifname === 'lo') continue
for (const a of i.addr_info ?? []) {
ipOptions.push({
value: a.local,
label: `${a.local}${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
})
}
}
const initial: SettingsForm | undefined = data ? {
...data,
listen_addresses: data.listen_addresses.split(',').map(s => s.trim()).filter(Boolean),
} : undefined
const save = useMutation({
mutationFn: async (v: SettingsForm) => {
const body: Settings = { ...v, listen_addresses: v.listen_addresses.join(', ') }
return (await apiClient.put('/ntp/settings', body)).data
},
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['ntp', 'settings'] })
},
onError: (e: Error) => message.error(e.message),
})
if (isLoading) return null
return (
<Form
form={form}
layout="vertical"
initialValues={initial}
onFinish={(v) => save.mutate(v)}
style={{ maxWidth: 720 }}
>
<Alert type="info" showIcon className="mb-12" message={t('ntp.settings.intro')} />
<Form.Item label={t('ntp.settings.serveClients')} name="serve_clients" valuePropName="checked"
extra={t('ntp.settings.serveClientsExtra')}>
<Switch />
</Form.Item>
<Form.Item label={t('ntp.settings.listenAddresses')} name="listen_addresses"
rules={[{ required: true, type: 'array', min: 1 }]}
extra={t('ntp.settings.listenAddressesExtra')}>
<Select mode="tags" options={ipOptions} showSearch optionFilterProp="value"
placeholder={t('ntp.settings.listenAddressesPlaceholder')} />
</Form.Item>
<Form.Item label={t('ntp.settings.allowACL')} name="allow_acl" rules={[{ required: true }]}
extra={t('ntp.settings.allowACLExtra')}>
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
</Form.Item>
<Space>
<Form.Item label={t('ntp.settings.makestepSecs')} name="makestep_secs"
extra={t('ntp.settings.makestepSecsExtra')}>
<InputNumber min={0} step={0.1} style={{ width: 120 }} />
</Form.Item>
<Form.Item label={t('ntp.settings.makestepLimit')} name="makestep_limit">
<InputNumber min={-1} max={100} style={{ width: 120 }} />
</Form.Item>
</Space>
<Form.Item label={t('ntp.settings.rtcsync')} name="rtcsync" valuePropName="checked"
extra={t('ntp.settings.rtcsyncExtra')}>
<Switch />
</Form.Item>
<Form.Item label={t('ntp.settings.leapsectz')} name="leapsectz"
extra={t('ntp.settings.leapsectzExtra')}>
<Input placeholder="right/UTC" allowClear />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={save.isPending}>
{t('common.save')}
</Button>
</Form.Item>
</Form>
)
}