diff --git a/VERSION b/VERSION
index b02d7a2..bcce8b7 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-1.1.102
+1.1.103
diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go
index 71b60c3..e80df40 100644
--- a/cmd/edgeguard-api/main.go
+++ b/cmd/edgeguard-api/main.go
@@ -60,7 +60,7 @@ import (
usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users"
)
-var version = "1.1.102"
+var version = "1.1.103"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go
index 4852091..fec8c1a 100644
--- a/cmd/edgeguard-ctl/main.go
+++ b/cmd/edgeguard-ctl/main.go
@@ -11,7 +11,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/setup"
)
-var version = "1.1.102"
+var version = "1.1.103"
const usage = `edgeguard-ctl — EdgeGuard CLI
diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go
index b863ae5..c6b5d24 100644
--- a/cmd/edgeguard-scheduler/main.go
+++ b/cmd/edgeguard-scheduler/main.go
@@ -35,7 +35,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
-var version = "1.1.102"
+var version = "1.1.103"
const (
// renewTickInterval — how often we re-evaluate expiring certs.
diff --git a/internal/handlers/dns.go b/internal/handlers/dns.go
index bf0a704..e787f77 100644
--- a/internal/handlers/dns.go
+++ b/internal/handlers/dns.go
@@ -5,6 +5,8 @@ import (
"errors"
"log/slog"
"os/exec"
+ "strconv"
+ "strings"
"github.com/gin-gonic/gin"
@@ -55,6 +57,7 @@ func (h *DNSHandler) Register(rg *gin.RouterGroup) {
g.GET("/settings", h.GetSettings)
g.PUT("/settings", h.UpdateSettings)
+ g.GET("/stats", h.Stats)
g.POST("/flush-cache", h.FlushCache)
}
@@ -325,6 +328,71 @@ func validateZone(z *models.DNSZone) error {
return nil
}
+// Stats liefert Unbound-Resolver-Statistiken via `unbound-control stats_noreset`.
+// stats_noreset liest die Zähler ohne sie zurückzusetzen — safe für
+// wiederholte Aufrufe aus dem UI.
+func (h *DNSHandler) Stats(c *gin.Context) {
+ out, err := exec.Command("unbound-control", "stats_noreset").Output()
+ if err != nil {
+ response.OK(c, gin.H{
+ "error": "unbound-control nicht verfügbar: " + err.Error(),
+ })
+ return
+ }
+ response.OK(c, gin.H{"stats": parseUnboundStats(string(out))})
+}
+
+type unboundStats struct {
+ TotalQueries int64 `json:"total_queries"`
+ CacheHits int64 `json:"cache_hits"`
+ CacheMiss int64 `json:"cache_miss"`
+ CacheHitPct float64 `json:"cache_hit_pct"`
+ RecursiveReplies int64 `json:"recursive_replies"`
+ Prefetch int64 `json:"prefetch"`
+ RateLimited int64 `json:"rate_limited"`
+ RRSetCacheBytes int64 `json:"rrset_cache_bytes"`
+ MsgCacheBytes int64 `json:"msg_cache_bytes"`
+ TCPUsage int64 `json:"tcp_usage"`
+ Unwanted int64 `json:"unwanted"`
+}
+
+func parseUnboundStats(out string) unboundStats {
+ s := unboundStats{}
+ for _, line := range strings.Split(out, "\n") {
+ k, v, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
+ switch strings.TrimSpace(k) {
+ case "total.num.queries":
+ s.TotalQueries = n
+ case "total.num.cachehits":
+ s.CacheHits = n
+ case "total.num.cachemiss":
+ s.CacheMiss = n
+ case "total.num.recursivereplies":
+ s.RecursiveReplies = n
+ case "total.num.prefetch":
+ s.Prefetch = n
+ case "total.num.queries_ip_ratelimited":
+ s.RateLimited = n
+ case "mem.cache.rrset":
+ s.RRSetCacheBytes = n
+ case "mem.cache.message":
+ s.MsgCacheBytes = n
+ case "total.tcpusage":
+ s.TCPUsage = n
+ case "unwanted.queries":
+ s.Unwanted = n
+ }
+ }
+ if s.TotalQueries > 0 {
+ s.CacheHitPct = float64(s.CacheHits) / float64(s.TotalQueries) * 100
+ }
+ return s
+}
+
func validateRecord(r *models.DNSRecord) error {
if r.Name == "" {
return errors.New("name required")
diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json
index 94e17a1..af66b6e 100644
--- a/management-ui/src/i18n/locales/de/common.json
+++ b/management-ui/src/i18n/locales/de/common.json
@@ -883,7 +883,23 @@
"dns": {
"title": "DNS (Unbound)",
"intro": "Unbound-Resolver auf :53. Lokale Zonen (authoritativ aus DNS-Records) und Forward-Zonen (per stub-zone weiter zu fremden Resolvern). Default-Forwarder für alles andere.",
- "tabs": { "zones": "Zonen", "settings": "Resolver-Settings" },
+ "tabs": { "zones": "Zonen", "settings": "Resolver-Settings", "stats": "Resolver-Stats" },
+ "statsCard": {
+ "title": "Resolver-Statistiken (unbound-control stats_noreset)",
+ "totalQueries": "Abfragen gesamt",
+ "cacheHits": "Cache-Treffer",
+ "cacheMiss": "Cache-Miss",
+ "cacheHitPct": "Cache-Trefferrate",
+ "recursiveReplies": "Rekursive Antworten",
+ "prefetch": "Prefetch",
+ "rateLimited": "Rate-Limited",
+ "rrsetCacheBytes": "RRset-Cache",
+ "msgCacheBytes": "Msg-Cache",
+ "tcpUsage": "TCP-Verbindungen",
+ "unwanted": "Unerwünschte Anfragen",
+ "empty": "Keine Statistiken — läuft unbound?",
+ "sinceRestart": "Seit letztem Neustart"
+ },
"zone": {
"name": "Zone-Name",
"nameExtra": "FQDN ohne führenden/abschließenden Punkt — z.B. internal.netcell-it.de",
diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json
index 16d271d..ef8b3ee 100644
--- a/management-ui/src/i18n/locales/en/common.json
+++ b/management-ui/src/i18n/locales/en/common.json
@@ -883,7 +883,23 @@
"dns": {
"title": "DNS (Unbound)",
"intro": "Unbound resolver on :53. Local zones (authoritative from DNS records) and forward zones (stub-zone to remote resolvers). Default forwarders catch everything else.",
- "tabs": { "zones": "Zones", "settings": "Resolver settings" },
+ "tabs": { "zones": "Zones", "settings": "Resolver settings", "stats": "Resolver stats" },
+ "statsCard": {
+ "title": "Resolver statistics (unbound-control stats_noreset)",
+ "totalQueries": "Total queries",
+ "cacheHits": "Cache hits",
+ "cacheMiss": "Cache miss",
+ "cacheHitPct": "Cache hit rate",
+ "recursiveReplies": "Recursive replies",
+ "prefetch": "Prefetch",
+ "rateLimited": "Rate-limited",
+ "rrsetCacheBytes": "RRset cache",
+ "msgCacheBytes": "Msg cache",
+ "tcpUsage": "TCP connections",
+ "unwanted": "Unwanted queries",
+ "empty": "No stats available — is unbound running?",
+ "sinceRestart": "Since last restart"
+ },
"zone": {
"name": "Zone name",
"nameExtra": "FQDN without leading/trailing dot — e.g. internal.netcell-it.de",
diff --git a/management-ui/src/pages/DNS/index.tsx b/management-ui/src/pages/DNS/index.tsx
index 116e689..4e50601 100644
--- a/management-ui/src/pages/DNS/index.tsx
+++ b/management-ui/src/pages/DNS/index.tsx
@@ -1,7 +1,7 @@
import { useState } from 'react'
-import { Alert, Button, Drawer, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
+import { Alert, Button, Card, Col, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
-import { CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
+import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@@ -109,6 +109,7 @@ export default function DNSPage() {
)}
items={[
{ key: 'zones', label: {t('dns.tabs.zones')}, children: },
+ { key: 'stats', label: {t('dns.tabs.stats')}, children: },
{ key: 'settings', label: {t('dns.tabs.settings')}, children: },
]}
/>
@@ -566,3 +567,112 @@ function SettingsTab() {
)
}
+
+// ── Stats tab ──────────────────────────────────────────────────
+
+interface UnboundStats {
+ total_queries: number
+ cache_hits: number
+ cache_miss: number
+ cache_hit_pct: number
+ recursive_replies: number
+ prefetch: number
+ rate_limited: number
+ rrset_cache_bytes: number
+ msg_cache_bytes: number
+ tcp_usage: number
+ unwanted: number
+}
+
+function fmtBytes(n: number): string {
+ if (n >= 1_073_741_824) return (n / 1_073_741_824).toFixed(1) + ' GB'
+ if (n >= 1_048_576) return (n / 1_048_576).toFixed(1) + ' MB'
+ if (n >= 1_024) return (n / 1_024).toFixed(0) + ' KB'
+ return n + ' B'
+}
+
+function StatsTab() {
+ const { t } = useTranslation()
+ const { data, isFetching, refetch, error } = useQuery({
+ queryKey: ['dns', 'stats'],
+ queryFn: async () => {
+ const r = await apiClient.get('/dns/stats')
+ if (!isEnvelope(r.data)) return null
+ return r.data.data as { stats: UnboundStats; error?: string }
+ },
+ refetchInterval: 30_000,
+ })
+
+ const stats = data?.stats
+ const apiErr = data?.error
+
+ return (
+ {t('dns.statsCard.title')}>}
+ extra={} loading={isFetching} onClick={() => refetch()}>{t('common.refresh')}}
+ >
+ {(error || apiErr) && (
+
+ )}
+ {!stats ? (
+ {t('dns.statsCard.empty')}
+ ) : (
+
+
+ {t('dns.statsCard.sinceRestart')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {stats.rate_limited > 0 && (
+
+
+
+ )}
+ {stats.unwanted > 0 && (
+
+
+
+ )}
+
+
+
+ {t('dns.statsCard.cacheHitPct')}: {stats.cache_hit_pct.toFixed(1)}%
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/management-ui/src/pages/Firewall/NATRules.tsx b/management-ui/src/pages/Firewall/NATRules.tsx
index 1b674a9..03a0956 100644
--- a/management-ui/src/pages/Firewall/NATRules.tsx
+++ b/management-ui/src/pages/Firewall/NATRules.tsx
@@ -1,9 +1,9 @@
-import { useState } from 'react'
+import { useMemo, useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
-import { BranchesOutlined } from '@ant-design/icons'
+import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined } from '@ant-design/icons'
import DataTable from '../../components/DataTable'
import EmptyState from '../../components/EmptyState'
@@ -94,6 +94,20 @@ export default function NATRulesTab() {
onError: (e: Error) => message.error(e.message),
})
+ const sortedNAT = useMemo(
+ () => [...(data ?? [])].sort((a, b) => a.priority - b.priority),
+ [data],
+ )
+
+ const swapNAT = useMutation({
+ mutationFn: async ({ a, b }: { a: NATRule; b: NATRule }) => {
+ await apiClient.put(`/firewall/nat-rules/${a.id}`, { ...a, priority: b.priority })
+ await apiClient.put(`/firewall/nat-rules/${b.id}`, { ...b, priority: a.priority })
+ },
+ onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
+ onError: (e: Error) => message.error(e.message),
+ })
+
const renderTarget = (r: NATRule) => {
if (r.kind === 'masquerade') return {r.out_zone ?? '?'}-iface IP
if (!r.target_addr) return '—'
@@ -129,6 +143,27 @@ export default function NATRulesTab() {
/>
),
},
+ {
+ title: '', key: 'move', width: 64,
+ render: (_, row) => {
+ const idx = sortedNAT.findIndex(r => r.id === row.id)
+ const swapping = swapNAT.isPending
+ return (
+
+
+ }
+ disabled={isViewer || idx <= 0 || swapping}
+ onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx - 1] })} />
+
+
+ }
+ disabled={isViewer || idx >= sortedNAT.length - 1 || swapping}
+ onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx + 1] })} />
+
+
+ )
+ },
+ },
{
title: t('common.edit'), key: 'actions',
render: (_, row) => (