feat(cluster): PG Logical Replication + VIP/Keepalived + config_hash sync (v1.2.1–1.2.2)
- PG Logical Replication: edgeguard_shared PUBLICATION auf Primary, edgeguard_sub SUBSCRIPTION auf Secondary. Nur geteilte Config-Tabellen werden repliziert; node-eigene Daten (network_interfaces, ip_addresses, static_routes, cluster_settings, dns_settings, ntp_settings) bleiben lokal — OPNsense-Muster. - cluster-init-replication: Erstellt PUBLICATION, Rolle + pg_hba-Einträge (logical + replication), WAL-Level auf logical. - cluster-setup-standby: Erstellt SUBSCRIPTION (copy_data=true), pollt pg_subscription_rel bis alle Tabellen sync = 'r', rendert dann Configs. - promote: manueller Failover via pg_promote() + touch recovery.signal. - VIP/Keepalived: cluster_settings-Tabelle (vip_address, vip_interface, vrrp_router_id), /cluster/vip-settings API, Keepalived-Config-Generator mit VRRP + check_script + notify-Skripten in /usr/lib/edgeguard/scripts/. - config_hash sync: Secondary pusht alle 5 Min seinen Hash via mTLS an Primary (PushSelfToPrimary). Heartbeat schreibt nur LOCAL, daher ohne aktiven Push wäre Primary-Sicht des Secondary-Hash stale gewesen. - runSecondaryConfigRender: Goroutine auf Secondary rendert HAProxy+nftables neu wenn config_hash sich ändert (Logical-Replication-Nachzügler). - confighash: node-spezifische Tabellen aus hashSpec entfernt. - postinst: Keepalived-Skripte installieren, sudoers für keepalived. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,7 @@ interface HANode {
|
||||
internal_ip?: string | null
|
||||
mgmt_ip?: string | null
|
||||
role: string
|
||||
pg_role: 'standalone' | 'primary' | 'standby'
|
||||
version?: string | null
|
||||
config_hash?: string | null
|
||||
status: 'online' | 'offline' | 'joining' | 'leaving' | 'unknown'
|
||||
@@ -291,6 +292,13 @@ export default function ClusterPage() {
|
||||
title: t('cluster.col.role'), dataIndex: 'role', width: 110,
|
||||
render: (v: string) => <Tag color={v === 'primary' ? 'gold' : 'default'}>{v}</Tag>,
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.pgRole'), dataIndex: 'pg_role', width: 110,
|
||||
render: (v?: string) => {
|
||||
if (!v || v === 'standalone') return <Tag>{t('cluster.pgRole.standalone')}</Tag>
|
||||
return <Tag color={v === 'primary' ? 'blue' : 'cyan'}>{t(`cluster.pgRole.${v}`)}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('cluster.col.version'), dataIndex: 'version', width: 100,
|
||||
render: (v?: string | null) => v ? <Tag>{v}</Tag> : <Text type="secondary">—</Text>,
|
||||
@@ -525,6 +533,13 @@ export default function ClusterPage() {
|
||||
{data.local_node.role}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('cluster.col.pgRole')}>
|
||||
{(() => {
|
||||
const v = data.local_node.pg_role
|
||||
if (!v || v === 'standalone') return <Tag>{t('cluster.pgRole.standalone')}</Tag>
|
||||
return <Tag color={v === 'primary' ? 'blue' : 'cyan'}>{t(`cluster.pgRole.${v}`)}</Tag>
|
||||
})()}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label={t('cluster.col.version')}>
|
||||
{data.local_node.version ? <Tag>{data.local_node.version}</Tag> : '—'}
|
||||
</Descriptions.Item>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd'
|
||||
import { CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { ApartmentOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -34,12 +34,20 @@ interface ChangePasswordValues {
|
||||
confirm_password: string
|
||||
}
|
||||
|
||||
interface VIPSettingsValues {
|
||||
vip_address?: string
|
||||
vip_interface?: string
|
||||
vip_auth_pass?: string
|
||||
vrrp_router_id?: number
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const qc = useQueryClient()
|
||||
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||
const [msg, msgCtx] = message.useMessage()
|
||||
const [pwForm] = Form.useForm<ChangePasswordValues>()
|
||||
const [vipForm] = Form.useForm<VIPSettingsValues>()
|
||||
|
||||
const { data: setupStatus, isLoading: loadingSetup } = useQuery({
|
||||
queryKey: ['setup', 'status'],
|
||||
@@ -59,6 +67,27 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: vipSettings } = useQuery({
|
||||
queryKey: ['cluster', 'vip-settings'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/vip-settings')
|
||||
return isEnvelope(r.data) ? r.data.data as VIPSettingsValues : null
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (vipSettings) vipForm.setFieldsValue(vipSettings)
|
||||
}, [vipSettings, vipForm])
|
||||
|
||||
const updateVIP = useMutation({
|
||||
mutationFn: async (v: VIPSettingsValues) => apiClient.put('/cluster/vip-settings', v),
|
||||
onSuccess: () => {
|
||||
msg.success(t('cluster.vipCard.saved'))
|
||||
void qc.invalidateQueries({ queryKey: ['cluster', 'vip-settings'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('cluster.vipCard.saveFailed') + ': ' + e.message),
|
||||
})
|
||||
|
||||
const [emailForm] = Form.useForm<ContactEmailValues>()
|
||||
const updateEmails = useMutation({
|
||||
mutationFn: async (v: ContactEmailValues) => {
|
||||
@@ -768,6 +797,58 @@ export default function SettingsPage() {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><ApartmentOutlined /> {t('cluster.vipCard.title')}</>}
|
||||
size="small"
|
||||
className="mb-12"
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-12"
|
||||
message={t('cluster.vipCard.hintTitle')}
|
||||
description={
|
||||
<ul style={{ margin: '4px 0', paddingLeft: 18 }}>
|
||||
<li><Typography.Text code>{t('cluster.vipCard.hintPrimary')}</Typography.Text></li>
|
||||
<li><Typography.Text code>{t('cluster.vipCard.hintStandby')}</Typography.Text></li>
|
||||
<li><Typography.Text code>{t('cluster.vipCard.hintKeepalived')}</Typography.Text></li>
|
||||
<li><Typography.Text code>{t('cluster.vipCard.hintFailover')}</Typography.Text></li>
|
||||
</ul>
|
||||
}
|
||||
/>
|
||||
<Form<VIPSettingsValues>
|
||||
form={vipForm}
|
||||
layout="vertical"
|
||||
onFinish={(v) => updateVIP.mutate(v)}
|
||||
initialValues={{ vrrp_router_id: 51 }}
|
||||
>
|
||||
<Form.Item label={t('cluster.vipCard.vipAddress')} name="vip_address"
|
||||
extra={t('cluster.vipCard.vipAddressHelp')}>
|
||||
<Input placeholder="89.163.205.10" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('cluster.vipCard.vipInterface')} name="vip_interface"
|
||||
extra={t('cluster.vipCard.vipInterfaceHelp')}>
|
||||
<Input placeholder="eth0" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('cluster.vipCard.vipAuthPass')} name="vip_auth_pass"
|
||||
extra={t('cluster.vipCard.vipAuthPassHelp')}
|
||||
rules={[{ max: 8, message: 'Max. 8 Zeichen (Keepalived-Limit)' }]}>
|
||||
<Input.Password placeholder="max 8 chars" disabled={isViewer} />
|
||||
</Form.Item>
|
||||
<Form.Item label={t('cluster.vipCard.vrrpRouterId')} name="vrrp_router_id"
|
||||
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
|
||||
<InputNumber min={1} max={255} disabled={isViewer} />
|
||||
</Form.Item>
|
||||
{!isViewer && (
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>
|
||||
{t('cluster.vipCard.saveBtn')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
|
||||
<Form<ChangePasswordValues>
|
||||
form={pwForm}
|
||||
|
||||
Reference in New Issue
Block a user