feat(dns): Validierung für DNS-Settings — upstream IPs, ACL-CIDRs, TTL-Kreuzcheck

Backend: validateSettings() prüft vor dem Reload-Trigger ob upstream_forwards
gültige IPs (inkl. @port), access_acl gültige IPs/CIDRs und listen_addresses
gültige IPs sind; cache_max_ttl ≥ cache_min_ttl.
Frontend: Pattern-Validatoren auf upstream_forwards + access_acl; TTL-
Kreuzvalidierung mit dependencies.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-29 12:52:28 +02:00
parent 0cbc781d4f
commit 31d3485a2f
8 changed files with 113 additions and 12 deletions

View File

@@ -1 +1 @@
1.1.144
1.1.145

View File

@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
var version = "1.1.144"
var version = "1.1.145"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")

View File

@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
var version = "1.1.144"
var version = "1.1.145"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -41,7 +41,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.1.144"
var version = "1.1.145"
const (
// renewTickInterval — how often we re-evaluate expiring certs.

View File

@@ -3,7 +3,9 @@ package handlers
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os/exec"
"strconv"
"strings"
@@ -286,6 +288,10 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, err)
return
}
if err := validateSettings(&req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
@@ -312,6 +318,52 @@ func (h *DNSHandler) FlushCache(c *gin.Context) {
// ── Validation ─────────────────────────────────────────────────
// validateSettings checks user-supplied DNS global settings before they
// reach unbound. A malformed upstream IP or CIDR would cause unbound to
// fail on the next reload without any visible error.
func validateSettings(s *models.DNSSettings) error {
if s.ListenPort < 1 || s.ListenPort > 65535 {
return fmt.Errorf("listen_port %d out of range (1-65535)", s.ListenPort)
}
if s.CacheMaxTTL < s.CacheMinTTL {
return fmt.Errorf("cache_max_ttl (%d) must be ≥ cache_min_ttl (%d)", s.CacheMaxTTL, s.CacheMinTTL)
}
for _, raw := range strings.Split(s.UpstreamForwards, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
// strip optional @port suffix (e.g. 1.1.1.1@853)
host, _, _ := strings.Cut(entry, "@")
if net.ParseIP(host) == nil {
return fmt.Errorf("invalid upstream forwarder IP: %q", host)
}
}
for _, raw := range strings.Split(s.AccessACL, ",") {
entry := strings.TrimSpace(raw)
if entry == "" {
continue
}
if strings.Contains(entry, "/") {
if _, _, err := net.ParseCIDR(entry); err != nil {
return fmt.Errorf("invalid access ACL CIDR: %q", entry)
}
} else if net.ParseIP(entry) == nil {
return fmt.Errorf("invalid access ACL IP: %q", entry)
}
}
for _, raw := range strings.Split(s.ListenAddresses, ",") {
addr := strings.TrimSpace(raw)
if addr == "" {
continue
}
if net.ParseIP(addr) == nil {
return fmt.Errorf("invalid listen address: %q", addr)
}
}
return nil
}
func validateZone(z *models.DNSZone) error {
if z.Name == "" {
return errors.New("name required")

View File

@@ -990,7 +990,10 @@
"flushCacheBtn": "DNS-Cache leeren",
"flushCacheTooltip": "Alle gecachten Einträge verwerfen (unbound-control flush_zone .). Verwenden wenn DNS-Änderungen sofort greifen sollen.",
"flushCacheOk": "DNS-Cache geleert",
"flushCacheFailed": "Cache-Flush fehlgeschlagen"
"flushCacheFailed": "Cache-Flush fehlgeschlagen",
"upstreamForwardsInvalid": "Jeder Forwarder muss eine gültige IP sein (z.B. 1.1.1.1 oder 9.9.9.9)",
"accessACLInvalid": "Jeder Eintrag muss eine gültige IP oder CIDR sein (z.B. 10.0.0.0/8)",
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein"
}
},
"fwd": {

View File

@@ -990,7 +990,10 @@
"flushCacheBtn": "Flush DNS cache",
"flushCacheTooltip": "Discard all cached records (unbound-control flush_zone .). Use after DNS changes have propagated.",
"flushCacheOk": "DNS cache flushed",
"flushCacheFailed": "Flush failed"
"flushCacheFailed": "Flush failed",
"upstreamForwardsInvalid": "Each forwarder must be a valid IP (e.g. 1.1.1.1 or 9.9.9.9)",
"accessACLInvalid": "Each entry must be a valid IP or CIDR (e.g. 10.0.0.0/8 or 192.168.1.0/24)",
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL"
}
},
"fwd": {

View File

@@ -524,12 +524,40 @@ function SettingsTab() {
<Form.Item label={t('dns.settings.listenPort')} name="listen_port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('dns.settings.upstreamForwards')} name="upstream_forwards" rules={[{ required: true }]}
extra={t('dns.settings.upstreamForwardsExtra')}>
<Form.Item
label={t('dns.settings.upstreamForwards')}
name="upstream_forwards"
extra={t('dns.settings.upstreamForwardsExtra')}
rules={[
{ required: true },
{
validator(_, val: string) {
if (!val) return Promise.resolve()
const IP = /^(\d{1,3}\.){3}\d{1,3}(@\d{1,5})?$|^[0-9a-fA-F:]+(@\d{1,5})?$/
const bad = val.split(',').map(s => s.trim()).filter(Boolean).find(s => !IP.test(s))
return bad ? Promise.reject(new Error(t('dns.settings.upstreamForwardsInvalid'))) : Promise.resolve()
},
},
]}
>
<Input placeholder="1.1.1.1, 9.9.9.9" />
</Form.Item>
<Form.Item label={t('dns.settings.accessACL')} name="access_acl" rules={[{ required: true }]}
extra={t('dns.settings.accessACLExtra')}>
<Form.Item
label={t('dns.settings.accessACL')}
name="access_acl"
extra={t('dns.settings.accessACLExtra')}
rules={[
{ required: true },
{
validator(_, val: string) {
if (!val) return Promise.resolve()
const IPCIDR = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$|^[0-9a-fA-F:]+(\/\d{1,3})?$/
const bad = val.split(',').map(s => s.trim()).filter(Boolean).find(s => !IPCIDR.test(s))
return bad ? Promise.reject(new Error(t('dns.settings.accessACLInvalid'))) : Promise.resolve()
},
},
]}
>
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
</Form.Item>
<Form.Item label={t('dns.settings.dnssec')} name="dnssec" valuePropName="checked">
@@ -539,10 +567,25 @@ function SettingsTab() {
<Switch />
</Form.Item>
<Space>
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl">
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
<InputNumber min={0} style={{ width: 120 }} />
</Form.Item>
<Form.Item label={t('dns.settings.cacheMax')} name="cache_max_ttl">
<Form.Item
label={t('dns.settings.cacheMax')}
name="cache_max_ttl"
dependencies={['cache_min_ttl']}
rules={[
({ getFieldValue }) => ({
validator(_, val) {
const minTTL = getFieldValue('cache_min_ttl') as number | undefined
if (val != null && minTTL != null && val < minTTL) {
return Promise.reject(new Error(t('dns.settings.cacheTTLError')))
}
return Promise.resolve()
},
}),
]}
>
<InputNumber min={60} style={{ width: 120 }} />
</Form.Item>
</Space>