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:
Debian
2026-05-29 23:40:37 +02:00
parent c1a4ccff8f
commit 25c7cd0cb5
19 changed files with 1128 additions and 40 deletions

View File

@@ -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>

View File

@@ -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}