feat(ipv6): IPv6-Support für HAProxy + Settings-Toggle

- setup.State.IPv6Enabled + Store.SetIPv6Enabled()
- GET/POST /system/ipv6 im SystemHandler; HAProxy-Reload on save
- HAProxy-Template: bind [::]:80, [::]:443, quic6@:443, [::]:3443
  werden nur emittiert wenn IPv6Enabled=true
- haproxy.View.IPv6Enabled aus SetupStore befüllt
- Settings-UI: neues IPv6-Card (zwischen Auto-Update und Passwort)
- i18n de+en ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-23 16:48:26 +02:00
parent 906c2e17a6
commit 4e01b4569c
8 changed files with 128 additions and 2 deletions

View File

@@ -1 +1 @@
1.1.56 1.1.57

View File

@@ -85,6 +85,8 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
g.POST("/render-configs", h.RenderConfigs) g.POST("/render-configs", h.RenderConfigs)
g.POST("/service-restart", h.ServiceRestart) g.POST("/service-restart", h.ServiceRestart)
g.GET("/upgrade-status", h.UpgradeStatus) g.GET("/upgrade-status", h.UpgradeStatus)
g.GET("/ipv6", h.IPv6)
g.POST("/ipv6", h.SetIPv6)
} }
// RegisterAgent mountet die read-only System-Endpoints auf der mTLS- // RegisterAgent mountet die read-only System-Endpoints auf der mTLS-
@@ -413,6 +415,42 @@ type dbSizeResponse struct {
Tables []dbSizeTable `json:"top_tables"` Tables []dbSizeTable `json:"top_tables"`
} }
func (h *SystemHandler) IPv6(c *gin.Context) {
enabled := false
if h.Setup != nil {
if st, err := h.Setup.Load(); err == nil && st != nil {
enabled = st.IPv6Enabled
}
}
response.OK(c, gin.H{"enabled": enabled})
}
func (h *SystemHandler) SetIPv6(c *gin.Context) {
if h.Setup == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("setup not initialised"))
return
}
var req struct {
Enabled bool `json:"enabled"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if err := h.Setup.SetIPv6Enabled(req.Enabled); err != nil {
response.BadRequest(c, err)
return
}
if h.HAProxyReloader != nil {
_ = h.HAProxyReloader(c.Request.Context())
}
if h.Audit != nil {
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.ipv6",
"", gin.H{"enabled": req.Enabled}, h.NodeID)
}
response.OK(c, gin.H{"enabled": req.Enabled})
}
func (h *SystemHandler) DBSize(c *gin.Context) { func (h *SystemHandler) DBSize(c *gin.Context) {
if h.Pool == nil { if h.Pool == nil {
response.Err(c, http.StatusServiceUnavailable, simpleErr("db pool unavailable")) response.Err(c, http.StatusServiceUnavailable, simpleErr("db pool unavailable"))

View File

@@ -35,6 +35,9 @@ defaults
# Everything else redirects to HTTPS. # Everything else redirects to HTTPS.
frontend public_http frontend public_http
bind :80 bind :80
{{- if .IPv6Enabled}}
bind [::]:80
{{- end}}
acl is_acme path_beg /.well-known/acme-challenge/ acl is_acme path_beg /.well-known/acme-challenge/
@@ -59,6 +62,10 @@ frontend public_https
# gebaut; Browser fallen via Alt-Svc-Header (siehe unten) für # gebaut; Browser fallen via Alt-Svc-Header (siehe unten) für
# Folge-Requests auf h3 zurück. # Folge-Requests auf h3 zurück.
bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3 bind quic4@:443 ssl crt /etc/edgeguard/tls/ alpn h3
{{- if .IPv6Enabled}}
bind [::]:443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
bind quic6@:443 ssl crt /etc/edgeguard/tls/ alpn h3
{{- end}}
# Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist. # Alt-Svc: signalisiert dass h3 auf demselben Port verfügbar ist.
# ma=86400 = Browser darf den Hinweis 24h cachen. # ma=86400 = Browser darf den Hinweis 24h cachen.
@@ -134,6 +141,9 @@ frontend public_https
# (Hostname egal — default_backend), inkl. der direkten IP. # (Hostname egal — default_backend), inkl. der direkten IP.
frontend mgmt_https frontend mgmt_https
bind :3443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1 bind :3443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
{{- if .IPv6Enabled}}
bind [::]:3443 ssl crt /etc/edgeguard/tls/ alpn h2,http/1.1
{{- end}}
http-response set-header Strict-Transport-Security "max-age=31536000" http-response set-header Strict-Transport-Security "max-age=31536000"
http-request set-header X-Forwarded-Proto https http-request set-header X-Forwarded-Proto https
http-request set-header X-Real-IP %[src] http-request set-header X-Real-IP %[src]

View File

@@ -128,6 +128,10 @@ type View struct {
// OS-Upgrades / Wartungsfenster. // OS-Upgrades / Wartungsfenster.
GlobalMaintenance bool GlobalMaintenance bool
GlobalMaintenanceMessage string GlobalMaintenanceMessage string
// IPv6Enabled: wenn true fügt das Template zusätzliche
// bind-Direktiven für [::]:80, [::]:443 und [::]:3443 hinzu.
IPv6Enabled bool
} }
type DomainView struct { type DomainView struct {
@@ -270,6 +274,7 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) {
if v.GlobalMaintenanceMessage == "" && v.GlobalMaintenance { if v.GlobalMaintenanceMessage == "" && v.GlobalMaintenance {
v.GlobalMaintenanceMessage = "EdgeGuard maintenance in progress." v.GlobalMaintenanceMessage = "EdgeGuard maintenance in progress."
} }
v.IPv6Enabled = st.IPv6Enabled
} }
} }
return v, nil return v, nil

View File

@@ -57,6 +57,11 @@ type State struct {
// Compliance (z. B. SOX = 7 Jahre = 2555) oder reduzieren falls // Compliance (z. B. SOX = 7 Jahre = 2555) oder reduzieren falls
// /var-Disk-Druck. // /var-Disk-Druck.
AuditRetentionDays int `json:"audit_retention_days,omitempty"` AuditRetentionDays int `json:"audit_retention_days,omitempty"`
// IPv6Enabled: wenn true bindet HAProxy zusätzlich auf [::]:80,
// [::]:443 und [::]:3443. Default false weil nicht alle Deployments
// IPv6 haben. Nach Änderung wird HAProxy neu geladen.
IPv6Enabled bool `json:"ipv6_enabled,omitempty"`
} }
// Request is the JSON body POST /api/v1/setup/complete accepts. // Request is the JSON body POST /api/v1/setup/complete accepts.
@@ -192,6 +197,19 @@ func (s *Store) SetAuditRetention(days int) error {
return s.Save(prev) return s.Save(prev)
} }
// SetIPv6Enabled schaltet IPv6-Binds in HAProxy an oder aus.
func (s *Store) SetIPv6Enabled(enabled bool) error {
prev, err := s.Load()
if err != nil {
return err
}
if prev == nil {
return errors.New("setup not completed — cannot edit IPv6 before initial setup")
}
prev.IPv6Enabled = enabled
return s.Save(prev)
}
// SetContactEmails aktualisiert AdminEmail + ACMEEmail. Beide werden // SetContactEmails aktualisiert AdminEmail + ACMEEmail. Beide werden
// gegen mail.ParseAddress validiert. Lower-case + trim wie beim Setup- // gegen mail.ParseAddress validiert. Lower-case + trim wie beim Setup-
// Wizard, damit Login-Vergleich (EqualFold) konsistent bleibt. // Wizard, damit Login-Vergleich (EqualFold) konsistent bleibt.

View File

@@ -558,6 +558,12 @@
"autoUpdateHint": "Whitelist umfasst nur edgeguard, edgeguard-api, edgeguard-ui. Andere Pakete bleiben unter manueller Kontrolle. Verlangt unattended-upgrades (Distro-Standard auf Trixie). Conf-File: /etc/apt/apt.conf.d/52edgeguard-auto-updates.", "autoUpdateHint": "Whitelist umfasst nur edgeguard, edgeguard-api, edgeguard-ui. Andere Pakete bleiben unter manueller Kontrolle. Verlangt unattended-upgrades (Distro-Standard auf Trixie). Conf-File: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
"autoUpdateToggled": "Auto-Update-Einstellung gespeichert.", "autoUpdateToggled": "Auto-Update-Einstellung gespeichert.",
"autoUpdateFailed": "Auto-Update-Toggle fehlgeschlagen", "autoUpdateFailed": "Auto-Update-Toggle fehlgeschlagen",
"ipv6CardTitle": "IPv6",
"ipv6On": "Aktiviert — HAProxy bindet zusätzlich zu IPv4 auf [::]:80, [::]:443 und [::]:3443.",
"ipv6Off": "Deaktiviert — HAProxy lauscht nur auf IPv4.",
"ipv6Hint": "Erfordert IPv6-Konfiguration auf den Netzwerk-Interfaces dieses Servers. HAProxy wird nach der Änderung automatisch neu geladen.",
"ipv6Toggled": "IPv6-Einstellung gespeichert.",
"ipv6Failed": "IPv6-Toggle fehlgeschlagen",
"passwordCardTitle": "Admin-Passwort ändern", "passwordCardTitle": "Admin-Passwort ändern",
"currentPassword": "Aktuelles Passwort", "currentPassword": "Aktuelles Passwort",
"newPassword": "Neues Passwort", "newPassword": "Neues Passwort",

View File

@@ -558,6 +558,12 @@
"autoUpdateHint": "Whitelist covers edgeguard, edgeguard-api, edgeguard-ui only. Other packages stay under manual control. Requires unattended-upgrades (Trixie distro default). Conf file: /etc/apt/apt.conf.d/52edgeguard-auto-updates.", "autoUpdateHint": "Whitelist covers edgeguard, edgeguard-api, edgeguard-ui only. Other packages stay under manual control. Requires unattended-upgrades (Trixie distro default). Conf file: /etc/apt/apt.conf.d/52edgeguard-auto-updates.",
"autoUpdateToggled": "Auto-update setting saved.", "autoUpdateToggled": "Auto-update setting saved.",
"autoUpdateFailed": "Auto-update toggle failed", "autoUpdateFailed": "Auto-update toggle failed",
"ipv6CardTitle": "IPv6",
"ipv6On": "Enabled — HAProxy binds on [::]:80, [::]:443 and [::]:3443 in addition to IPv4.",
"ipv6Off": "Disabled — HAProxy listens on IPv4 only.",
"ipv6Hint": "Requires IPv6 to be configured on this server's network interfaces. HAProxy reloads automatically after changing this setting.",
"ipv6Toggled": "IPv6 setting saved.",
"ipv6Failed": "IPv6 toggle failed",
"passwordCardTitle": "Change admin password", "passwordCardTitle": "Change admin password",
"currentPassword": "Current password", "currentPassword": "Current password",
"newPassword": "New password", "newPassword": "New password",

View File

@@ -1,5 +1,5 @@
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Space, Spin, Switch, Typography, message } from 'antd' import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Space, Spin, Switch, Typography, message } from 'antd'
import { CloudDownloadOutlined, CloudSyncOutlined, DatabaseOutlined, ExclamationCircleOutlined, FileSearchOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons' import { CloudDownloadOutlined, CloudSyncOutlined, DatabaseOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -239,6 +239,27 @@ export default function SettingsPage() {
}, },
}) })
const { data: ipv6 } = useQuery({
queryKey: ['system', 'ipv6'],
queryFn: async () => {
const r = await apiClient.get('/system/ipv6')
return isEnvelope(r.data) ? (r.data.data as { enabled: boolean }) : { enabled: false }
},
})
const toggleIPv6 = useMutation({
mutationFn: async (enabled: boolean) => {
const r = await apiClient.post('/system/ipv6', { enabled })
return r.data
},
onSuccess: () => {
msg.success(t('settings.ipv6Toggled'))
void qc.invalidateQueries({ queryKey: ['system', 'ipv6'] })
},
onError: (e: Error) => {
msg.error(t('settings.ipv6Failed') + ': ' + e.message)
},
})
const changePassword = useMutation({ const changePassword = useMutation({
mutationFn: async (v: { current_password: string; new_password: string }) => { mutationFn: async (v: { current_password: string; new_password: string }) => {
const r = await apiClient.post('/auth/change-password', v) const r = await apiClient.post('/auth/change-password', v)
@@ -606,6 +627,28 @@ export default function SettingsPage() {
</Space> </Space>
</Card> </Card>
<Card
title={<><GlobalOutlined /> {t('settings.ipv6CardTitle')}</>}
className="mb-12"
size="small"
>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space>
<Switch
checked={ipv6?.enabled ?? false}
loading={toggleIPv6.isPending}
onChange={(checked) => toggleIPv6.mutate(checked)}
/>
<Typography.Text>
{ipv6?.enabled ? t('settings.ipv6On') : t('settings.ipv6Off')}
</Typography.Text>
</Space>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('settings.ipv6Hint')}
</Typography.Text>
</Space>
</Card>
<Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small"> <Card title={<><LockOutlined /> {t('settings.passwordCardTitle')}</>} size="small">
<Form<ChangePasswordValues> <Form<ChangePasswordValues>
form={pwForm} form={pwForm}