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:
@@ -60,7 +60,7 @@ import (
|
|||||||
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.144"
|
var version = "1.1.145"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
|
"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
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.1.144"
|
var version = "1.1.145"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -286,6 +288,10 @@ func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := validateSettings(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -312,6 +318,52 @@ func (h *DNSHandler) FlushCache(c *gin.Context) {
|
|||||||
|
|
||||||
// ── Validation ─────────────────────────────────────────────────
|
// ── 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 {
|
func validateZone(z *models.DNSZone) error {
|
||||||
if z.Name == "" {
|
if z.Name == "" {
|
||||||
return errors.New("name required")
|
return errors.New("name required")
|
||||||
|
|||||||
@@ -990,7 +990,10 @@
|
|||||||
"flushCacheBtn": "DNS-Cache leeren",
|
"flushCacheBtn": "DNS-Cache leeren",
|
||||||
"flushCacheTooltip": "Alle gecachten Einträge verwerfen (unbound-control flush_zone .). Verwenden wenn DNS-Änderungen sofort greifen sollen.",
|
"flushCacheTooltip": "Alle gecachten Einträge verwerfen (unbound-control flush_zone .). Verwenden wenn DNS-Änderungen sofort greifen sollen.",
|
||||||
"flushCacheOk": "DNS-Cache geleert",
|
"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": {
|
"fwd": {
|
||||||
|
|||||||
@@ -990,7 +990,10 @@
|
|||||||
"flushCacheBtn": "Flush DNS cache",
|
"flushCacheBtn": "Flush DNS cache",
|
||||||
"flushCacheTooltip": "Discard all cached records (unbound-control flush_zone .). Use after DNS changes have propagated.",
|
"flushCacheTooltip": "Discard all cached records (unbound-control flush_zone .). Use after DNS changes have propagated.",
|
||||||
"flushCacheOk": "DNS cache flushed",
|
"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": {
|
"fwd": {
|
||||||
|
|||||||
@@ -524,12 +524,40 @@ function SettingsTab() {
|
|||||||
<Form.Item label={t('dns.settings.listenPort')} name="listen_port" rules={[{ required: true }]}>
|
<Form.Item label={t('dns.settings.listenPort')} name="listen_port" rules={[{ required: true }]}>
|
||||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('dns.settings.upstreamForwards')} name="upstream_forwards" rules={[{ required: true }]}
|
<Form.Item
|
||||||
extra={t('dns.settings.upstreamForwardsExtra')}>
|
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" />
|
<Input placeholder="1.1.1.1, 9.9.9.9" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('dns.settings.accessACL')} name="access_acl" rules={[{ required: true }]}
|
<Form.Item
|
||||||
extra={t('dns.settings.accessACLExtra')}>
|
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" />
|
<Input placeholder="127.0.0.0/8, 10.0.0.0/8" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('dns.settings.dnssec')} name="dnssec" valuePropName="checked">
|
<Form.Item label={t('dns.settings.dnssec')} name="dnssec" valuePropName="checked">
|
||||||
@@ -539,10 +567,25 @@ function SettingsTab() {
|
|||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Space>
|
<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 }} />
|
<InputNumber min={0} style={{ width: 120 }} />
|
||||||
</Form.Item>
|
</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 }} />
|
<InputNumber min={60} style={{ width: 120 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
Reference in New Issue
Block a user