feat: Unbound DNS-Resolver — vollständig (Renderer + Handler + UI)
Stub raus, vollständig implementiert:
* Migration 0014: dns_settings (single-row) + dns_zones.forward_to.
Default-Settings sind sinnvoll für die typische LAN-Resolver-Rolle
(1.1.1.1 + 9.9.9.9 upstream, localnet allow, DNSSEC + qname-min on).
* internal/services/dns: CRUD-Repo für zones, records, settings.
* internal/handlers/dns.go: REST /api/v1/dns/zones, /records, /settings
mit Auto-Reload nach jeder Mutation.
* internal/unbound/unbound.cfg.tpl + unbound.go: Renderer schreibt
/etc/unbound/unbound.conf.d/edgeguard.conf direkt (kein Symlink-
Dance, weil AppArmor unbound nur /etc/unbound erlaubt). Local-zones
authoritativ aus dns_records; forward-zones per stub-zone; default-
forwarders catchen alles sonst.
* main.go: dnsRepo + unbound-Reloader injiziert.
* render.go: unbound.New() bekommt Pool.
* postinst:
- Conf-Datei /etc/unbound/unbound.conf.d/edgeguard.conf wird als
edgeguard:edgeguard 0644 angelegt damit Renderer schreiben kann.
- /etc/edgeguard + Service-Subdirs auf 0755 (Squid + Unbound laufen
NICHT als edgeguard, brauchen Read-Traversal).
- Sudoers: systemctl reload unbound.service whitelisted.
* Template: chroot:"" (Conf liegt außerhalb /var/lib/unbound default-
chroot), DNSSEC-Trust-Anchor NICHT setzen (Distro hat schon
root-auto-trust-anchor-file.conf — sonst doppelter Anchor → start
failure).
* Frontend /dns: PageHeader + zwei Tabs (Zones + Resolver-Settings).
Zones-Tab mit Drawer für Records (CRUD pro Zone, A/AAAA/CNAME/TXT/
MX/SRV/NS/PTR/CAA). Sidebar-Eintrag unter Network.
* i18n DE/EN für dns.* Block.
Verified end-to-end: render → unbound restart → dig @127.0.0.1
example.com → 104.20.23.154 / 172.66.147.243.
Version 1.0.34 (mehrere Iterationen wegen AppArmor + chroot + perms).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,11 +23,13 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/haproxy"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers"
|
||||||
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
squidrender "git.netcell-it.de/projekte/edgeguard-native/internal/squid"
|
||||||
|
unboundrender "git.netcell-it.de/projekte/edgeguard-native/internal/unbound"
|
||||||
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/acme"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/backends"
|
||||||
|
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/domains"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/forwardproxy"
|
||||||
@@ -41,7 +43,7 @@ import (
|
|||||||
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.26"
|
var version = "1.0.34"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -144,6 +146,7 @@ func main() {
|
|||||||
wgIfaces := wgsvc.NewInterfacesRepo(pool)
|
wgIfaces := wgsvc.NewInterfacesRepo(pool)
|
||||||
wgPeers := wgsvc.NewPeersRepo(pool)
|
wgPeers := wgsvc.NewPeersRepo(pool)
|
||||||
fwdProxyRepo := forwardproxy.New(pool)
|
fwdProxyRepo := forwardproxy.New(pool)
|
||||||
|
dnsRepo := dnssvc.New(pool)
|
||||||
|
|
||||||
// ACME (Let's Encrypt). Email comes from setup.json — the
|
// ACME (Let's Encrypt). Email comes from setup.json — the
|
||||||
// wizard collects acme_email and the issuer registers an
|
// wizard collects acme_email and the issuer registers an
|
||||||
@@ -192,6 +195,13 @@ func main() {
|
|||||||
return squidrender.New(pool).Render(ctx)
|
return squidrender.New(pool).Render(ctx)
|
||||||
}
|
}
|
||||||
handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, squidReloader).Register(authed)
|
handlers.NewForwardProxyHandler(fwdProxyRepo, auditRepo, nodeID, squidReloader).Register(authed)
|
||||||
|
|
||||||
|
// Unbound DNS reload — re-render edgeguard.conf + reload
|
||||||
|
// unbound.service.
|
||||||
|
unboundReloader := func(ctx context.Context) error {
|
||||||
|
return unboundrender.New(pool).Render(ctx)
|
||||||
|
}
|
||||||
|
handlers.NewDNSHandler(dnsRepo, auditRepo, nodeID, unboundReloader).Register(authed)
|
||||||
}
|
}
|
||||||
|
|
||||||
mountUI(r)
|
mountUI(r)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.26"
|
var version = "1.0.34"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func cmdRenderConfig(args []string) int {
|
|||||||
fw := firewall.New(pool)
|
fw := firewall.New(pool)
|
||||||
sq := squid.New(pool)
|
sq := squid.New(pool)
|
||||||
wg := wireguard.New(pool, secrets.New(""))
|
wg := wireguard.New(pool, secrets.New(""))
|
||||||
ub := unbound.New()
|
ub := unbound.New(pool)
|
||||||
if skipReload {
|
if skipReload {
|
||||||
hap.SkipReload = true
|
hap.SkipReload = true
|
||||||
fw.SkipReload = true
|
fw.SkipReload = true
|
||||||
|
|||||||
@@ -21,7 +21,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.0.26"
|
var version = "1.0.34"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// renewTickInterval — how often we re-evaluate expiring certs.
|
// renewTickInterval — how often we re-evaluate expiring certs.
|
||||||
|
|||||||
35
internal/database/migrations/0014_dns_forward_targets.sql
Normal file
35
internal/database/migrations/0014_dns_forward_targets.sql
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
-- +goose Up
|
||||||
|
-- +goose StatementBegin
|
||||||
|
|
||||||
|
-- Forward-zone-Targets: pro dns_zones-Row vom type='forward' steht
|
||||||
|
-- hier die Komma-separierte Upstream-Liste (z.B. "10.0.0.53, 8.8.8.8").
|
||||||
|
-- Für type='local' bleibt das Feld NULL — local-data kommt aus
|
||||||
|
-- dns_records.
|
||||||
|
ALTER TABLE dns_zones
|
||||||
|
ADD COLUMN IF NOT EXISTS forward_to TEXT;
|
||||||
|
|
||||||
|
-- Plus: globale Settings-Tabelle (single-row) für die DNS-Resolver-
|
||||||
|
-- Konfiguration. listen_addresses ist Komma-separiert; access_acl
|
||||||
|
-- gibt die CIDR-Liste die den Resolver benutzen darf.
|
||||||
|
CREATE TABLE IF NOT EXISTS dns_settings (
|
||||||
|
id BIGINT PRIMARY KEY DEFAULT 1,
|
||||||
|
listen_addresses TEXT NOT NULL DEFAULT '127.0.0.1, ::1',
|
||||||
|
listen_port INTEGER NOT NULL DEFAULT 53,
|
||||||
|
upstream_forwards TEXT NOT NULL DEFAULT '1.1.1.1, 9.9.9.9, 1.0.0.1',
|
||||||
|
access_acl TEXT NOT NULL DEFAULT '127.0.0.0/8, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
|
||||||
|
dnssec BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
qname_minimisation BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
cache_min_ttl INTEGER NOT NULL DEFAULT 60,
|
||||||
|
cache_max_ttl INTEGER NOT NULL DEFAULT 86400,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT dns_settings_singleton CHECK (id = 1)
|
||||||
|
);
|
||||||
|
INSERT INTO dns_settings (id) VALUES (1) ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- +goose StatementEnd
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- +goose StatementBegin
|
||||||
|
DROP TABLE IF EXISTS dns_settings;
|
||||||
|
ALTER TABLE dns_zones DROP COLUMN IF EXISTS forward_to;
|
||||||
|
-- +goose StatementEnd
|
||||||
328
internal/handlers/dns.go
Normal file
328
internal/handlers/dns.go
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
||||||
|
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DNSHandler exposes /api/v1/dns/zones + /records + /settings.
|
||||||
|
type DNSHandler struct {
|
||||||
|
Repo *dnssvc.Repo
|
||||||
|
Audit *audit.Repo
|
||||||
|
NodeID string
|
||||||
|
Reloader func(ctx context.Context) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDNSHandler(repo *dnssvc.Repo, a *audit.Repo, nodeID string, reloader func(context.Context) error) *DNSHandler {
|
||||||
|
return &DNSHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) reload(ctx context.Context, op string) {
|
||||||
|
if h.Reloader == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Reloader(ctx); err != nil {
|
||||||
|
slog.Warn("unbound: reload after mutation failed", "op", op, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) Register(rg *gin.RouterGroup) {
|
||||||
|
g := rg.Group("/dns")
|
||||||
|
|
||||||
|
z := g.Group("/zones")
|
||||||
|
z.GET("", h.ListZones)
|
||||||
|
z.POST("", h.CreateZone)
|
||||||
|
z.GET("/:id", h.GetZone)
|
||||||
|
z.PUT("/:id", h.UpdateZone)
|
||||||
|
z.DELETE("/:id", h.DeleteZone)
|
||||||
|
z.GET("/:id/records", h.ListRecordsForZone)
|
||||||
|
z.POST("/:id/records", h.CreateRecord)
|
||||||
|
|
||||||
|
r := g.Group("/records")
|
||||||
|
r.GET("", h.ListAllRecords)
|
||||||
|
r.GET("/:id", h.GetRecord)
|
||||||
|
r.PUT("/:id", h.UpdateRecord)
|
||||||
|
r.DELETE("/:id", h.DeleteRecord)
|
||||||
|
|
||||||
|
g.GET("/settings", h.GetSettings)
|
||||||
|
g.PUT("/settings", h.UpdateSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zones ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *DNSHandler) ListZones(c *gin.Context) {
|
||||||
|
out, err := h.Repo.ListZones(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"zones": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) GetZone(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
z, err := h.Repo.GetZone(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, dnssvc.ErrZoneNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, z)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) CreateZone(c *gin.Context) {
|
||||||
|
var req models.DNSZone
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateZone(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Repo.CreateZone(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.create", out.Name, out, h.NodeID)
|
||||||
|
response.Created(c, out)
|
||||||
|
h.reload(c.Request.Context(), "zone.create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) UpdateZone(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.DNSZone
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateZone(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Repo.UpdateZone(c.Request.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, dnssvc.ErrZoneNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.zone.update", out.Name, out, h.NodeID)
|
||||||
|
response.OK(c, out)
|
||||||
|
h.reload(c.Request.Context(), "zone.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) DeleteZone(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Repo.DeleteZone(c.Request.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, dnssvc.ErrZoneNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(c)
|
||||||
|
h.reload(c.Request.Context(), "zone.delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Records ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *DNSHandler) ListRecordsForZone(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Repo.ListRecordsForZone(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"records": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) ListAllRecords(c *gin.Context) {
|
||||||
|
out, err := h.Repo.ListAllRecords(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"records": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) GetRecord(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r, err := h.Repo.GetRecord(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, dnssvc.ErrRecordNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) CreateRecord(c *gin.Context) {
|
||||||
|
zoneID, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.DNSRecord
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ZoneID = zoneID
|
||||||
|
if err := validateRecord(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Repo.CreateRecord(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.create", out.Name, out, h.NodeID)
|
||||||
|
response.Created(c, out)
|
||||||
|
h.reload(c.Request.Context(), "record.create")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) UpdateRecord(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req models.DNSRecord
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateRecord(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Repo.UpdateRecord(c.Request.Context(), id, req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, dnssvc.ErrRecordNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.record.update", out.Name, out, h.NodeID)
|
||||||
|
response.OK(c, out)
|
||||||
|
h.reload(c.Request.Context(), "record.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) DeleteRecord(c *gin.Context) {
|
||||||
|
id, ok := parseID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.Repo.DeleteRecord(c.Request.Context(), id); err != nil {
|
||||||
|
if errors.Is(err, dnssvc.ErrRecordNotFound) {
|
||||||
|
response.NotFound(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.NoContent(c)
|
||||||
|
h.reload(c.Request.Context(), "record.delete")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *DNSHandler) GetSettings(c *gin.Context) {
|
||||||
|
s, err := h.Repo.GetSettings(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.OK(c, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *DNSHandler) UpdateSettings(c *gin.Context) {
|
||||||
|
var req models.DNSSettings
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.BadRequest(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.Repo.UpdateSettings(c.Request.Context(), req)
|
||||||
|
if err != nil {
|
||||||
|
response.Internal(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "dns.settings.update", "settings", out, h.NodeID)
|
||||||
|
response.OK(c, out)
|
||||||
|
h.reload(c.Request.Context(), "settings.update")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Validation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func validateZone(z *models.DNSZone) error {
|
||||||
|
if z.Name == "" {
|
||||||
|
return errors.New("name required")
|
||||||
|
}
|
||||||
|
switch z.ZoneType {
|
||||||
|
case "local":
|
||||||
|
// no extra fields required
|
||||||
|
case "forward":
|
||||||
|
if z.ForwardTo == nil || *z.ForwardTo == "" {
|
||||||
|
return errors.New("forward zone requires forward_to (comma-separated upstream IPs)")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("zone_type must be 'local' or 'forward'")
|
||||||
|
}
|
||||||
|
if z.ManagedBy == "" {
|
||||||
|
z.ManagedBy = "user"
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRecord(r *models.DNSRecord) error {
|
||||||
|
if r.Name == "" {
|
||||||
|
return errors.New("name required")
|
||||||
|
}
|
||||||
|
if r.Value == "" {
|
||||||
|
return errors.New("value required")
|
||||||
|
}
|
||||||
|
switch r.RecordType {
|
||||||
|
case "A", "AAAA", "CNAME", "TXT", "MX", "SRV", "NS", "PTR", "CAA":
|
||||||
|
default:
|
||||||
|
return errors.New("record_type must be A/AAAA/CNAME/TXT/MX/SRV/NS/PTR/CAA")
|
||||||
|
}
|
||||||
|
if r.TTL == 0 {
|
||||||
|
r.TTL = 300
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -2,12 +2,17 @@ package models
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// DNSZone is one Unbound zone:
|
||||||
|
// - zone_type='local': authoritative, records aus dns_records
|
||||||
|
// - zone_type='forward': stub-zone, forward_to ist Komma-Liste
|
||||||
|
// von upstream-IPs (z.B. "10.0.0.53, 8.8.8.8")
|
||||||
type DNSZone struct {
|
type DNSZone struct {
|
||||||
ID int64 `gorm:"primaryKey" json:"id"`
|
ID int64 `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
Name string `gorm:"column:name;uniqueIndex" json:"name"`
|
||||||
ZoneType string `gorm:"column:zone_type" json:"zone_type"`
|
ZoneType string `gorm:"column:zone_type" json:"zone_type"`
|
||||||
Description *string `gorm:"column:description" json:"description,omitempty"`
|
Description *string `gorm:"column:description" json:"description,omitempty"`
|
||||||
ManagedBy string `gorm:"column:managed_by" json:"managed_by"`
|
ManagedBy string `gorm:"column:managed_by" json:"managed_by"`
|
||||||
|
ForwardTo *string `gorm:"column:forward_to" json:"forward_to,omitempty"`
|
||||||
Active bool `gorm:"column:active" json:"active"`
|
Active bool `gorm:"column:active" json:"active"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
@@ -15,6 +20,8 @@ type DNSZone struct {
|
|||||||
|
|
||||||
func (DNSZone) TableName() string { return "dns_zones" }
|
func (DNSZone) TableName() string { return "dns_zones" }
|
||||||
|
|
||||||
|
// DNSRecord — A/AAAA/CNAME/TXT/MX/SRV/NS/PTR/CAA. Value ist die
|
||||||
|
// RDATA in Textform (für MX: "10 mail.example.com").
|
||||||
type DNSRecord struct {
|
type DNSRecord struct {
|
||||||
ID int64 `gorm:"primaryKey" json:"id"`
|
ID int64 `gorm:"primaryKey" json:"id"`
|
||||||
ZoneID int64 `gorm:"column:zone_id" json:"zone_id"`
|
ZoneID int64 `gorm:"column:zone_id" json:"zone_id"`
|
||||||
@@ -28,3 +35,21 @@ type DNSRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (DNSRecord) TableName() string { return "dns_records" }
|
func (DNSRecord) TableName() string { return "dns_records" }
|
||||||
|
|
||||||
|
// DNSSettings ist eine Single-Row-Tabelle mit globalen Resolver-
|
||||||
|
// Optionen. Default kommt aus der Migration (alle Werte sinnvoll
|
||||||
|
// für die typische LAN-Resolver-Rolle).
|
||||||
|
type DNSSettings struct {
|
||||||
|
ID int64 `gorm:"primaryKey" json:"id"`
|
||||||
|
ListenAddresses string `gorm:"column:listen_addresses" json:"listen_addresses"`
|
||||||
|
ListenPort int `gorm:"column:listen_port" json:"listen_port"`
|
||||||
|
UpstreamForwards string `gorm:"column:upstream_forwards" json:"upstream_forwards"`
|
||||||
|
AccessACL string `gorm:"column:access_acl" json:"access_acl"`
|
||||||
|
DNSSEC bool `gorm:"column:dnssec" json:"dnssec"`
|
||||||
|
QNameMinimisation bool `gorm:"column:qname_minimisation" json:"qname_minimisation"`
|
||||||
|
CacheMinTTL int `gorm:"column:cache_min_ttl" json:"cache_min_ttl"`
|
||||||
|
CacheMaxTTL int `gorm:"column:cache_max_ttl" json:"cache_max_ttl"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DNSSettings) TableName() string { return "dns_settings" }
|
||||||
|
|||||||
260
internal/services/dns/dns.go
Normal file
260
internal/services/dns/dns.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
// Package dns provides CRUD against dns_zones, dns_records and the
|
||||||
|
// single-row dns_settings table. Renderer in internal/unbound consumes
|
||||||
|
// the same data to emit /etc/edgeguard/unbound/edgeguard.conf.
|
||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrZoneNotFound = errors.New("dns zone not found")
|
||||||
|
ErrRecordNotFound = errors.New("dns record not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repo struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||||
|
|
||||||
|
// ── Zones ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const zoneSelect = `
|
||||||
|
SELECT id, name, zone_type, description, managed_by, forward_to, active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM dns_zones
|
||||||
|
`
|
||||||
|
|
||||||
|
func (r *Repo) ListZones(ctx context.Context) ([]models.DNSZone, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, zoneSelect+" ORDER BY name")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]models.DNSZone, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
z, err := scanZone(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *z)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GetZone(ctx context.Context, id int64) (*models.DNSZone, error) {
|
||||||
|
row := r.Pool.QueryRow(ctx, zoneSelect+" WHERE id = $1", id)
|
||||||
|
z, err := scanZone(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrZoneNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return z, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateZone(ctx context.Context, z models.DNSZone) (*models.DNSZone, error) {
|
||||||
|
if z.ManagedBy == "" {
|
||||||
|
z.ManagedBy = "user"
|
||||||
|
}
|
||||||
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO dns_zones (name, zone_type, description, managed_by, forward_to, active)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, name, zone_type, description, managed_by, forward_to, active,
|
||||||
|
created_at, updated_at`,
|
||||||
|
z.Name, z.ZoneType, z.Description, z.ManagedBy, z.ForwardTo, z.Active)
|
||||||
|
return scanZone(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpdateZone(ctx context.Context, id int64, z models.DNSZone) (*models.DNSZone, error) {
|
||||||
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE dns_zones SET name=$1, zone_type=$2, description=$3, forward_to=$4, active=$5,
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE id=$6
|
||||||
|
RETURNING id, name, zone_type, description, managed_by, forward_to, active,
|
||||||
|
created_at, updated_at`,
|
||||||
|
z.Name, z.ZoneType, z.Description, z.ForwardTo, z.Active, id)
|
||||||
|
out, err := scanZone(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrZoneNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) DeleteZone(ctx context.Context, id int64) error {
|
||||||
|
tag, err := r.Pool.Exec(ctx, `DELETE FROM dns_zones WHERE id=$1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrZoneNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Records ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const recordSelect = `
|
||||||
|
SELECT id, zone_id, name, record_type, value, ttl, active,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM dns_records
|
||||||
|
`
|
||||||
|
|
||||||
|
func (r *Repo) ListRecordsForZone(ctx context.Context, zoneID int64) ([]models.DNSRecord, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, recordSelect+" WHERE zone_id=$1 ORDER BY name, record_type", zoneID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]models.DNSRecord, 0, 8)
|
||||||
|
for rows.Next() {
|
||||||
|
rec, err := scanRecord(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) ListAllRecords(ctx context.Context) ([]models.DNSRecord, error) {
|
||||||
|
rows, err := r.Pool.Query(ctx, recordSelect+" ORDER BY zone_id, name")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]models.DNSRecord, 0, 16)
|
||||||
|
for rows.Next() {
|
||||||
|
rec, err := scanRecord(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, *rec)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) GetRecord(ctx context.Context, id int64) (*models.DNSRecord, error) {
|
||||||
|
row := r.Pool.QueryRow(ctx, recordSelect+" WHERE id=$1", id)
|
||||||
|
rec, err := scanRecord(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rec, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) CreateRecord(ctx context.Context, rec models.DNSRecord) (*models.DNSRecord, error) {
|
||||||
|
if rec.TTL == 0 {
|
||||||
|
rec.TTL = 300
|
||||||
|
}
|
||||||
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO dns_records (zone_id, name, record_type, value, ttl, active)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6)
|
||||||
|
RETURNING id, zone_id, name, record_type, value, ttl, active,
|
||||||
|
created_at, updated_at`,
|
||||||
|
rec.ZoneID, rec.Name, rec.RecordType, rec.Value, rec.TTL, rec.Active)
|
||||||
|
return scanRecord(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpdateRecord(ctx context.Context, id int64, rec models.DNSRecord) (*models.DNSRecord, error) {
|
||||||
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE dns_records SET name=$1, record_type=$2, value=$3, ttl=$4, active=$5,
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE id=$6
|
||||||
|
RETURNING id, zone_id, name, record_type, value, ttl, active,
|
||||||
|
created_at, updated_at`,
|
||||||
|
rec.Name, rec.RecordType, rec.Value, rec.TTL, rec.Active, id)
|
||||||
|
out, err := scanRecord(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) DeleteRecord(ctx context.Context, id int64) error {
|
||||||
|
tag, err := r.Pool.Exec(ctx, `DELETE FROM dns_records WHERE id=$1`, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrRecordNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings (single row, id=1) ────────────────────────────────
|
||||||
|
|
||||||
|
func (r *Repo) GetSettings(ctx context.Context) (*models.DNSSettings, error) {
|
||||||
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
||||||
|
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at
|
||||||
|
FROM dns_settings WHERE id=1`)
|
||||||
|
var s models.DNSSettings
|
||||||
|
if err := row.Scan(&s.ID, &s.ListenAddresses, &s.ListenPort, &s.UpstreamForwards,
|
||||||
|
&s.AccessACL, &s.DNSSEC, &s.QNameMinimisation,
|
||||||
|
&s.CacheMinTTL, &s.CacheMaxTTL, &s.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repo) UpdateSettings(ctx context.Context, s models.DNSSettings) (*models.DNSSettings, error) {
|
||||||
|
row := r.Pool.QueryRow(ctx, `
|
||||||
|
UPDATE dns_settings SET
|
||||||
|
listen_addresses=$1, listen_port=$2, upstream_forwards=$3, access_acl=$4,
|
||||||
|
dnssec=$5, qname_minimisation=$6, cache_min_ttl=$7, cache_max_ttl=$8,
|
||||||
|
updated_at=NOW()
|
||||||
|
WHERE id=1
|
||||||
|
RETURNING id, listen_addresses, listen_port, upstream_forwards, access_acl,
|
||||||
|
dnssec, qname_minimisation, cache_min_ttl, cache_max_ttl, updated_at`,
|
||||||
|
s.ListenAddresses, s.ListenPort, s.UpstreamForwards, s.AccessACL,
|
||||||
|
s.DNSSEC, s.QNameMinimisation, s.CacheMinTTL, s.CacheMaxTTL)
|
||||||
|
var out models.DNSSettings
|
||||||
|
if err := row.Scan(&out.ID, &out.ListenAddresses, &out.ListenPort, &out.UpstreamForwards,
|
||||||
|
&out.AccessACL, &out.DNSSEC, &out.QNameMinimisation,
|
||||||
|
&out.CacheMinTTL, &out.CacheMaxTTL, &out.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── scan helpers ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
func scanZone(row interface{ Scan(...any) error }) (*models.DNSZone, error) {
|
||||||
|
var z models.DNSZone
|
||||||
|
if err := row.Scan(
|
||||||
|
&z.ID, &z.Name, &z.ZoneType, &z.Description, &z.ManagedBy,
|
||||||
|
&z.ForwardTo, &z.Active, &z.CreatedAt, &z.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &z, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanRecord(row interface{ Scan(...any) error }) (*models.DNSRecord, error) {
|
||||||
|
var rec models.DNSRecord
|
||||||
|
if err := row.Scan(
|
||||||
|
&rec.ID, &rec.ZoneID, &rec.Name, &rec.RecordType, &rec.Value, &rec.TTL,
|
||||||
|
&rec.Active, &rec.CreatedAt, &rec.UpdatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &rec, nil
|
||||||
|
}
|
||||||
@@ -76,19 +76,26 @@ func (g *Generator) Render(ctx context.Context) error {
|
|||||||
return configgen.ReloadService("squid")
|
return configgen.ReloadService("squid")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureDistroSymlink legt /etc/squid/squid.conf als Symlink auf
|
// ensureDistroSymlink prüft ob /etc/squid/squid.conf auf unsere
|
||||||
// unsere managed conf an. Squid systemd-Unit liest die Distro-Datei;
|
// managed conf zeigt. Setup ist Postinst-Verantwortung (Renderer
|
||||||
// ohne Symlink driftet der edgeguard-Renderer und der laufende
|
// hat als edgeguard-User kein Schreibrecht in /etc/squid). Wenn
|
||||||
// Daemon auseinander (gleicher Bug-Pattern wie wg-quick).
|
// Symlink fehlt → Warnung, aber kein Fehler — squid liest dann
|
||||||
// Existing real file (Distro-Default) wird nach .distro-bak verschoben,
|
// noch die Distro-Default und der Operator merkt's beim nächsten
|
||||||
// nicht gelöscht.
|
// reload.
|
||||||
func ensureDistroSymlink() error {
|
func ensureDistroSymlink() error {
|
||||||
const link = "/etc/squid/squid.conf"
|
const link = "/etc/squid/squid.conf"
|
||||||
if cur, err := os.Readlink(link); err == nil && cur == confPath {
|
if cur, err := os.Readlink(link); err == nil && cur == confPath {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// Versuch zu setzen — bei permission-denied (= edgeguard-User
|
||||||
|
// hat keinen Schreibrecht in /etc/squid) warnen + ok melden.
|
||||||
if _, err := os.Stat(link); err == nil {
|
if _, err := os.Stat(link); err == nil {
|
||||||
_ = os.Rename(link, link+".distro-bak")
|
_ = os.Rename(link, link+".distro-bak")
|
||||||
}
|
}
|
||||||
return os.Symlink(confPath, link)
|
if err := os.Symlink(confPath, link); err != nil {
|
||||||
|
// Postinst hat den Symlink schon angelegt oder soll's beim
|
||||||
|
// Upgrade nachholen. Renderer sollte hier nicht failen.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
79
internal/unbound/unbound.cfg.tpl
Normal file
79
internal/unbound/unbound.cfg.tpl
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# Generated by edgeguard — do not edit by hand.
|
||||||
|
# Re-generate via `edgeguard-ctl render-config --only=unbound`.
|
||||||
|
|
||||||
|
server:
|
||||||
|
verbosity: 1
|
||||||
|
use-syslog: yes
|
||||||
|
interface-automatic: no
|
||||||
|
|
||||||
|
# Kein chroot — unsere Conf-Datei liegt in /etc/edgeguard/unbound/
|
||||||
|
# und ist außerhalb des Distro-chroot (/var/lib/unbound) nicht
|
||||||
|
# erreichbar. Distro-Default chrooted; wir deaktivieren das hier
|
||||||
|
# explicit. Hardening passiert via systemd-Sandboxing der Unit.
|
||||||
|
chroot: ""
|
||||||
|
username: "unbound"
|
||||||
|
{{- range .ListenAddresses}}
|
||||||
|
interface: {{.}}@{{$.Settings.ListenPort}}
|
||||||
|
{{- end}}
|
||||||
|
port: {{.Settings.ListenPort}}
|
||||||
|
|
||||||
|
# Access control — wer darf den Resolver benutzen.
|
||||||
|
{{- range .AccessACLs}}
|
||||||
|
access-control: {{.}} allow
|
||||||
|
{{- end}}
|
||||||
|
access-control: 0.0.0.0/0 refuse
|
||||||
|
access-control: ::/0 refuse
|
||||||
|
|
||||||
|
# Cache + Resilience
|
||||||
|
do-ip4: yes
|
||||||
|
do-ip6: yes
|
||||||
|
do-udp: yes
|
||||||
|
do-tcp: yes
|
||||||
|
cache-min-ttl: {{.Settings.CacheMinTTL}}
|
||||||
|
cache-max-ttl: {{.Settings.CacheMaxTTL}}
|
||||||
|
msg-cache-size: 64m
|
||||||
|
rrset-cache-size: 128m
|
||||||
|
num-threads: 2
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
hide-identity: yes
|
||||||
|
hide-version: yes
|
||||||
|
harden-glue: yes
|
||||||
|
harden-dnssec-stripped: yes
|
||||||
|
harden-below-nxdomain: yes
|
||||||
|
harden-referral-path: yes
|
||||||
|
use-caps-for-id: yes
|
||||||
|
qname-minimisation: {{if .Settings.QNameMinimisation}}yes{{else}}no{{end}}
|
||||||
|
minimal-responses: yes
|
||||||
|
aggressive-nsec: yes
|
||||||
|
{{/* DNSSEC trust-anchor wird vom distro-snippet
|
||||||
|
/etc/unbound/unbound.conf.d/root-auto-trust-anchor-file.conf
|
||||||
|
gesetzt — hier keine eigene Zeile, sonst doppelt. Nur die
|
||||||
|
val-clean-additional-Option setzen wenn DNSSEC aktiv. */}}
|
||||||
|
{{- if .Settings.DNSSEC}}
|
||||||
|
val-clean-additional: yes
|
||||||
|
{{- end}}
|
||||||
|
|
||||||
|
# Local zones from operator (zone_type='local')
|
||||||
|
{{range .LocalZones}}
|
||||||
|
local-zone: "{{.Name}}." static
|
||||||
|
{{- range .Records}}
|
||||||
|
local-data: "{{.Name}}{{if not (hasSuffix .Name $.dot)}}.{{end}} {{.TTL}} IN {{.RecordType}} {{.Value}}"
|
||||||
|
{{- end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
# Forward zones from operator (zone_type='forward')
|
||||||
|
{{range .ForwardZones}}
|
||||||
|
forward-zone:
|
||||||
|
name: "{{.Name}}."
|
||||||
|
{{- range $f := .Forwarders}}
|
||||||
|
forward-addr: {{$f}}
|
||||||
|
{{- end}}
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
# Default upstream forwarders ("." catches everything not local).
|
||||||
|
forward-zone:
|
||||||
|
name: "."
|
||||||
|
{{- range $f := .Upstreams}}
|
||||||
|
forward-addr: {{$f}}
|
||||||
|
{{- end}}
|
||||||
@@ -1,20 +1,146 @@
|
|||||||
// Package unbound will render /etc/edgeguard/unbound/{forwarders,
|
// Package unbound renders /etc/edgeguard/unbound/edgeguard.conf from
|
||||||
// cluster-zone,access}.conf in Phase 3 (forwarder + cluster-internal
|
// dns_zones, dns_records and dns_settings, then reloads
|
||||||
// split-horizon, see docs/architecture.md §7.5). v1 ships a stub.
|
// unbound.service. Operator-managed local + forward zones; default
|
||||||
|
// global forwarders catch everything else.
|
||||||
|
//
|
||||||
|
// /etc/unbound/unbound.conf.d/edgeguard.conf wird auf unsere
|
||||||
|
// managed conf gesymlinked — die Distro-Default unbound.conf liest
|
||||||
|
// das Verzeichnis automatisch.
|
||||||
package unbound
|
package unbound
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/configgen"
|
||||||
|
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
|
||||||
|
dnssvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/dns"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Generator struct{}
|
// ConfPath ist der Distro-Standard-Drop-in-Pfad. Wir schreiben hier
|
||||||
|
// direkt rein (statt /etc/edgeguard/unbound/...) weil das distro
|
||||||
|
// AppArmor-Profil unbound nur Reads aus /etc/unbound erlaubt. Der
|
||||||
|
// edgeguard-User darf in die Datei schreiben — postinst legt sie
|
||||||
|
// initial mit chown edgeguard:edgeguard an.
|
||||||
|
const ConfPath = "/etc/unbound/unbound.conf.d/edgeguard.conf"
|
||||||
|
|
||||||
func New() *Generator { return &Generator{} }
|
//go:embed unbound.cfg.tpl
|
||||||
|
var cfgTpl string
|
||||||
|
|
||||||
|
var tpl = template.Must(template.New("unbound").Funcs(template.FuncMap{
|
||||||
|
"hasSuffix": strings.HasSuffix,
|
||||||
|
}).Parse(cfgTpl))
|
||||||
|
|
||||||
|
type View struct {
|
||||||
|
Settings *models.DNSSettings
|
||||||
|
ListenAddresses []string
|
||||||
|
AccessACLs []string
|
||||||
|
Upstreams []string
|
||||||
|
LocalZones []localZoneView
|
||||||
|
ForwardZones []forwardZoneView
|
||||||
|
dot string
|
||||||
|
}
|
||||||
|
|
||||||
|
type localZoneView struct {
|
||||||
|
Name string
|
||||||
|
Records []models.DNSRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type forwardZoneView struct {
|
||||||
|
Name string
|
||||||
|
Forwarders []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Generator struct {
|
||||||
|
Pool *pgxpool.Pool
|
||||||
|
Repo *dnssvc.Repo
|
||||||
|
SkipReload bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(pool *pgxpool.Pool) *Generator {
|
||||||
|
return &Generator{Pool: pool, Repo: dnssvc.New(pool)}
|
||||||
|
}
|
||||||
|
|
||||||
func (g *Generator) Name() string { return "unbound" }
|
func (g *Generator) Name() string { return "unbound" }
|
||||||
|
|
||||||
func (g *Generator) Render(ctx context.Context) error {
|
func (g *Generator) Render(ctx context.Context) error {
|
||||||
return configgen.ErrNotImplemented
|
settings, err := g.Repo.GetSettings(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("settings: %w", err)
|
||||||
|
}
|
||||||
|
zones, err := g.Repo.ListZones(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("zones: %w", err)
|
||||||
|
}
|
||||||
|
view := View{
|
||||||
|
Settings: settings,
|
||||||
|
ListenAddresses: splitCSV(settings.ListenAddresses),
|
||||||
|
AccessACLs: splitCSV(settings.AccessACL),
|
||||||
|
Upstreams: splitCSV(settings.UpstreamForwards),
|
||||||
|
dot: ".",
|
||||||
|
}
|
||||||
|
for _, z := range zones {
|
||||||
|
if !z.Active {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch z.ZoneType {
|
||||||
|
case "local":
|
||||||
|
recs, err := g.Repo.ListRecordsForZone(ctx, z.ID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("records for zone %s: %w", z.Name, err)
|
||||||
|
}
|
||||||
|
active := make([]models.DNSRecord, 0, len(recs))
|
||||||
|
for _, r := range recs {
|
||||||
|
if r.Active {
|
||||||
|
active = append(active, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
view.LocalZones = append(view.LocalZones, localZoneView{
|
||||||
|
Name: z.Name, Records: active,
|
||||||
|
})
|
||||||
|
case "forward":
|
||||||
|
fwd := []string{}
|
||||||
|
if z.ForwardTo != nil {
|
||||||
|
fwd = splitCSV(*z.ForwardTo)
|
||||||
|
}
|
||||||
|
view.ForwardZones = append(view.ForwardZones, forwardZoneView{
|
||||||
|
Name: z.Name, Forwarders: fwd,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body bytes.Buffer
|
||||||
|
if err := tpl.Execute(&body, view); err != nil {
|
||||||
|
return fmt.Errorf("template: %w", err)
|
||||||
|
}
|
||||||
|
// Kein tmp+rename — /etc/unbound/unbound.conf.d gehört root:root
|
||||||
|
// und edgeguard kann keine neuen Files anlegen. ConfPath selbst
|
||||||
|
// ist edgeguard-owned (postinst), also direkter overwrite ok.
|
||||||
|
// Verlust der Atomarität: Unbound liest erst beim reload, der
|
||||||
|
// erst NACH erfolgreichem write ausgeführt wird.
|
||||||
|
if err := os.WriteFile(ConfPath, body.Bytes(), 0o644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", ConfPath, err)
|
||||||
|
}
|
||||||
|
if g.SkipReload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return configgen.ReloadService("unbound")
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCSV(s string) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, part := range strings.Split(s, ",") {
|
||||||
|
p := strings.TrimSpace(part)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "edgeguard-management-ui",
|
"name": "edgeguard-management-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.26",
|
"version": "1.0.34",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ const SSLPage = lazy(() => import('./pages/SSL'))
|
|||||||
const FirewallPage = lazy(() => import('./pages/Firewall'))
|
const FirewallPage = lazy(() => import('./pages/Firewall'))
|
||||||
const WireguardPage = lazy(() => import('./pages/Wireguard'))
|
const WireguardPage = lazy(() => import('./pages/Wireguard'))
|
||||||
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
const ForwardProxyPage = lazy(() => import('./pages/ForwardProxy'))
|
||||||
|
const DNSPage = lazy(() => import('./pages/DNS'))
|
||||||
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
const ClusterPage = lazy(() => import('./pages/Cluster'))
|
||||||
const SettingsPage = lazy(() => import('./pages/Settings'))
|
const SettingsPage = lazy(() => import('./pages/Settings'))
|
||||||
|
|
||||||
@@ -103,6 +104,7 @@ export default function App() {
|
|||||||
<Route path="/firewall" element={<FirewallPage />} />
|
<Route path="/firewall" element={<FirewallPage />} />
|
||||||
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
<Route path="/vpn/wireguard" element={<WireguardPage />} />
|
||||||
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
<Route path="/forward-proxy" element={<ForwardProxyPage />} />
|
||||||
|
<Route path="/dns" element={<DNSPage />} />
|
||||||
<Route path="/cluster" element={<ClusterPage />} />
|
<Route path="/cluster" element={<ClusterPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ const NAV: NavSection[] = [
|
|||||||
{ path: '/networks', labelKey: 'nav.networks', icon: <ClusterOutlined /> },
|
{ path: '/networks', labelKey: 'nav.networks', icon: <ClusterOutlined /> },
|
||||||
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
|
{ path: '/ip-addresses', labelKey: 'nav.ipAddresses', icon: <NodeIndexOutlined /> },
|
||||||
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
|
{ path: '/ssl', labelKey: 'nav.ssl', icon: <SafetyCertificateOutlined /> },
|
||||||
|
{ path: '/dns', labelKey: 'nav.dns', icon: <GlobalOutlined /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -72,7 +73,7 @@ const NAV: NavSection[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const VERSION = '1.0.26'
|
const VERSION = '1.0.34'
|
||||||
|
|
||||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"vpn": "VPN",
|
"vpn": "VPN",
|
||||||
"wireguard": "WireGuard",
|
"wireguard": "WireGuard",
|
||||||
"forwardProxy": "Forward-Proxy",
|
"forwardProxy": "Forward-Proxy",
|
||||||
|
"dns": "DNS",
|
||||||
"firewall": "Firewall",
|
"firewall": "Firewall",
|
||||||
"cluster": "Cluster",
|
"cluster": "Cluster",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
@@ -399,6 +400,51 @@
|
|||||||
"wg": "WireGuard"
|
"wg": "WireGuard"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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" },
|
||||||
|
"zone": {
|
||||||
|
"name": "Zone-Name",
|
||||||
|
"nameExtra": "FQDN ohne führenden/abschließenden Punkt — z.B. internal.netcell-it.de",
|
||||||
|
"type": "Typ",
|
||||||
|
"typeLocal": "local — authoritativ (records hier)",
|
||||||
|
"typeForward": "forward — stub-zone zu fremdem Resolver",
|
||||||
|
"forwardTo": "Upstream-Resolver",
|
||||||
|
"forwardToExtra": "Komma-separierte IP-Liste — z.B. '10.0.0.53, 8.8.8.8'",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"records": "Records …",
|
||||||
|
"add": "Zone hinzufügen",
|
||||||
|
"edit": "Zone bearbeiten",
|
||||||
|
"deleteConfirm": "Zone {{name}} mit allen Records wirklich löschen?"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"name": "Name",
|
||||||
|
"nameExtra": "Relativ zur Zone (z.B. 'mailcow') oder FQDN mit abschließendem Punkt.",
|
||||||
|
"type": "Typ",
|
||||||
|
"value": "Wert",
|
||||||
|
"valueExtra": "RDATA in Textform: A → IP, CNAME → FQDN, MX → 'priority host', TXT → 'string'.",
|
||||||
|
"ttl": "TTL (sec)",
|
||||||
|
"drawerTitle": "DNS-Records",
|
||||||
|
"add": "Record hinzufügen",
|
||||||
|
"edit": "Record bearbeiten",
|
||||||
|
"deleteConfirm": "Record {{name}} wirklich löschen?"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"intro": "Globale Resolver-Settings. Änderungen hier reloaden Unbound automatisch.",
|
||||||
|
"listenAddresses": "Listen-Adressen",
|
||||||
|
"listenAddressesExtra": "Komma-separiert. Standard 127.0.0.1+::1 — wenn LAN-Clients fragen sollen, z.B. die LAN-Iface-IP zusätzlich (10.10.20.3).",
|
||||||
|
"listenPort": "Port",
|
||||||
|
"upstreamForwards": "Default-Forwarders",
|
||||||
|
"upstreamForwardsExtra": "Wo geht alles hin was nicht lokal ist. Default 1.1.1.1 + 9.9.9.9.",
|
||||||
|
"accessACL": "Access-ACL (CIDRs)",
|
||||||
|
"accessACLExtra": "Wer darf diesen Resolver benutzen.",
|
||||||
|
"dnssec": "DNSSEC validieren",
|
||||||
|
"qnameMin": "QName-Minimisation (privacy)",
|
||||||
|
"cacheMin": "Cache min-TTL",
|
||||||
|
"cacheMax": "Cache max-TTL"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fwd": {
|
"fwd": {
|
||||||
"title": "Forward-Proxy (Squid)",
|
"title": "Forward-Proxy (Squid)",
|
||||||
"intro": "Squid-basierter Forward-Proxy auf :3128. ACLs werden top-down nach Priority ausgewertet — first-match wins. Wenn keine Regel passt, gewinnt der Default: nur localnet (10/8, 172.16/12, 192.168/16) darf raus.",
|
"intro": "Squid-basierter Forward-Proxy auf :3128. ACLs werden top-down nach Priority ausgewertet — first-match wins. Wenn keine Regel passt, gewinnt der Default: nur localnet (10/8, 172.16/12, 192.168/16) darf raus.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"vpn": "VPN",
|
"vpn": "VPN",
|
||||||
"wireguard": "WireGuard",
|
"wireguard": "WireGuard",
|
||||||
"forwardProxy": "Forward proxy",
|
"forwardProxy": "Forward proxy",
|
||||||
|
"dns": "DNS",
|
||||||
"firewall": "Firewall",
|
"firewall": "Firewall",
|
||||||
"cluster": "Cluster",
|
"cluster": "Cluster",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
@@ -399,6 +400,51 @@
|
|||||||
"wg": "WireGuard"
|
"wg": "WireGuard"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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" },
|
||||||
|
"zone": {
|
||||||
|
"name": "Zone name",
|
||||||
|
"nameExtra": "FQDN without leading/trailing dot — e.g. internal.netcell-it.de",
|
||||||
|
"type": "Type",
|
||||||
|
"typeLocal": "local — authoritative (records here)",
|
||||||
|
"typeForward": "forward — stub-zone to remote resolver",
|
||||||
|
"forwardTo": "Upstream resolvers",
|
||||||
|
"forwardToExtra": "Comma-separated IP list — e.g. '10.0.0.53, 8.8.8.8'",
|
||||||
|
"description": "Description",
|
||||||
|
"records": "Records …",
|
||||||
|
"add": "Add zone",
|
||||||
|
"edit": "Edit zone",
|
||||||
|
"deleteConfirm": "Really delete zone {{name}} and all its records?"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"name": "Name",
|
||||||
|
"nameExtra": "Relative to zone (e.g. 'mailcow') or FQDN with trailing dot.",
|
||||||
|
"type": "Type",
|
||||||
|
"value": "Value",
|
||||||
|
"valueExtra": "RDATA in text form: A → IP, CNAME → FQDN, MX → 'priority host', TXT → 'string'.",
|
||||||
|
"ttl": "TTL (sec)",
|
||||||
|
"drawerTitle": "DNS records",
|
||||||
|
"add": "Add record",
|
||||||
|
"edit": "Edit record",
|
||||||
|
"deleteConfirm": "Really delete record {{name}}?"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"intro": "Global resolver settings. Saves reload Unbound automatically.",
|
||||||
|
"listenAddresses": "Listen addresses",
|
||||||
|
"listenAddressesExtra": "Comma-separated. Default 127.0.0.1+::1 — to let LAN clients query, add the LAN iface IP (e.g. 10.10.20.3).",
|
||||||
|
"listenPort": "Port",
|
||||||
|
"upstreamForwards": "Default forwarders",
|
||||||
|
"upstreamForwardsExtra": "Where everything not local goes. Default 1.1.1.1 + 9.9.9.9.",
|
||||||
|
"accessACL": "Access ACL (CIDRs)",
|
||||||
|
"accessACLExtra": "Who is allowed to use this resolver.",
|
||||||
|
"dnssec": "DNSSEC validation",
|
||||||
|
"qnameMin": "QName minimisation (privacy)",
|
||||||
|
"cacheMin": "Cache min-TTL",
|
||||||
|
"cacheMax": "Cache max-TTL"
|
||||||
|
}
|
||||||
|
},
|
||||||
"fwd": {
|
"fwd": {
|
||||||
"title": "Forward proxy (Squid)",
|
"title": "Forward proxy (Squid)",
|
||||||
"intro": "Squid-based forward proxy on :3128. ACLs are evaluated top-down by priority — first match wins. If no rule matches, the default permits only localnet (10/8, 172.16/12, 192.168/16).",
|
"intro": "Squid-based forward proxy on :3128. ACLs are evaluated top-down by priority — first match wins. If no rule matches, the default permits only localnet (10/8, 172.16/12, 192.168/16).",
|
||||||
|
|||||||
390
management-ui/src/pages/DNS/index.tsx
Normal file
390
management-ui/src/pages/DNS/index.tsx
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Alert, Button, Drawer, Form, Input, InputNumber, Modal, Select, Space, Switch, Tabs, Tag, Typography, message } from 'antd'
|
||||||
|
import type { ColumnsType } from 'antd/es/table'
|
||||||
|
import { GlobalOutlined, NodeIndexOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
|
import DataTable from '../../components/DataTable'
|
||||||
|
import PageHeader from '../../components/PageHeader'
|
||||||
|
import ActionButtons from '../../components/ActionButtons'
|
||||||
|
import StatusDot from '../../components/StatusDot'
|
||||||
|
|
||||||
|
const { Text } = Typography
|
||||||
|
|
||||||
|
interface Zone {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
zone_type: 'local' | 'forward'
|
||||||
|
description?: string | null
|
||||||
|
managed_by: string
|
||||||
|
forward_to?: string | null
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DNSRecord {
|
||||||
|
id: number
|
||||||
|
zone_id: number
|
||||||
|
name: string
|
||||||
|
record_type: string
|
||||||
|
value: string
|
||||||
|
ttl: number
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Settings {
|
||||||
|
listen_addresses: string
|
||||||
|
listen_port: number
|
||||||
|
upstream_forwards: string
|
||||||
|
access_acl: string
|
||||||
|
dnssec: boolean
|
||||||
|
qname_minimisation: boolean
|
||||||
|
cache_min_ttl: number
|
||||||
|
cache_max_ttl: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
|
||||||
|
|
||||||
|
async function listZones(): Promise<Zone[]> {
|
||||||
|
const r = await apiClient.get('/dns/zones')
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { zones?: Zone[] }).zones ?? []
|
||||||
|
}
|
||||||
|
async function listRecords(zoneID: number): Promise<DNSRecord[]> {
|
||||||
|
const r = await apiClient.get(`/dns/zones/${zoneID}/records`)
|
||||||
|
if (!isEnvelope(r.data)) return []
|
||||||
|
return (r.data.data as { records?: DNSRecord[] }).records ?? []
|
||||||
|
}
|
||||||
|
async function getSettings(): Promise<Settings | null> {
|
||||||
|
const r = await apiClient.get('/dns/settings')
|
||||||
|
return isEnvelope(r.data) ? (r.data.data as Settings) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DNSPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
icon={<GlobalOutlined />}
|
||||||
|
title={t('dns.title')}
|
||||||
|
subtitle={t('dns.intro')}
|
||||||
|
/>
|
||||||
|
<Tabs
|
||||||
|
defaultActiveKey="zones"
|
||||||
|
items={[
|
||||||
|
{ key: 'zones', label: <span><NodeIndexOutlined /> {t('dns.tabs.zones')}</span>, children: <ZonesTab /> },
|
||||||
|
{ key: 'settings', label: <span><SettingOutlined /> {t('dns.tabs.settings')}</span>, children: <SettingsTab /> },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Zones tab ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function ZonesTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ['dns', 'zones'], queryFn: listZones })
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<Zone | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [form] = Form.useForm<Zone>()
|
||||||
|
|
||||||
|
const [recordsZone, setRecordsZone] = useState<Zone | null>(null)
|
||||||
|
|
||||||
|
const upsert = useMutation({
|
||||||
|
mutationFn: async (v: Zone) => {
|
||||||
|
if (editing) return (await apiClient.put(`/dns/zones/${editing.id}`, v)).data
|
||||||
|
return (await apiClient.post('/dns/zones', v)).data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
setEditing(null); setCreating(false); form.resetFields()
|
||||||
|
void qc.invalidateQueries({ queryKey: ['dns', 'zones'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: number) => { await apiClient.delete(`/dns/zones/${id}`) },
|
||||||
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'zones'] }) },
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cols: ColumnsType<Zone> = [
|
||||||
|
{ title: t('dns.zone.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
||||||
|
{ title: t('dns.zone.type'), dataIndex: 'zone_type', key: 'zone_type',
|
||||||
|
render: (s: string) => <Tag color={s === 'local' ? 'blue' : 'purple'}>{s}</Tag> },
|
||||||
|
{ title: t('dns.zone.forwardTo'), dataIndex: 'forward_to', key: 'forward_to',
|
||||||
|
render: (v?: string | null) => v ? <Text code style={{ fontSize: 11 }}>{v}</Text> : '—' },
|
||||||
|
{ title: t('dns.zone.description'), dataIndex: 'description', key: 'description', render: (v?: string | null) => v ?? '—' },
|
||||||
|
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||||
|
{
|
||||||
|
title: t('common.actions'), key: 'actions',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={4}>
|
||||||
|
{row.zone_type === 'local' && (
|
||||||
|
<Button size="small" type="text" onClick={() => setRecordsZone(row)}>
|
||||||
|
{t('dns.zone.records')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<ActionButtons
|
||||||
|
onEdit={() => {
|
||||||
|
setEditing(row)
|
||||||
|
form.setFieldsValue(row)
|
||||||
|
}}
|
||||||
|
onDelete={() => del.mutate(row.id)}
|
||||||
|
deleteConfirm={t('dns.zone.deleteConfirm', { name: row.name })}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DataTable
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
columns={cols}
|
||||||
|
extraActions={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setCreating(true); form.resetFields()
|
||||||
|
form.setFieldsValue({ zone_type: 'local', active: true } as Zone)
|
||||||
|
}}>
|
||||||
|
{t('dns.zone.add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? t('dns.zone.edit') : t('dns.zone.add')}
|
||||||
|
open={editing !== null || creating}
|
||||||
|
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
|
||||||
|
onOk={() => { void form.submit() }}
|
||||||
|
confirmLoading={upsert.isPending}
|
||||||
|
width={580}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
|
||||||
|
<Form.Item label={t('dns.zone.name')} name="name" rules={[{ required: true }]}
|
||||||
|
extra={t('dns.zone.nameExtra')}>
|
||||||
|
<Input placeholder="internal.netcell-it.de" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.zone.type')} name="zone_type" rules={[{ required: true }]}>
|
||||||
|
<Select options={[
|
||||||
|
{ value: 'local', label: t('dns.zone.typeLocal') },
|
||||||
|
{ value: 'forward', label: t('dns.zone.typeForward') },
|
||||||
|
]} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item noStyle shouldUpdate={(p, c) => p.zone_type !== c.zone_type}>
|
||||||
|
{({ getFieldValue }) => getFieldValue('zone_type') === 'forward' && (
|
||||||
|
<Form.Item label={t('dns.zone.forwardTo')} name="forward_to" rules={[{ required: true }]}
|
||||||
|
extra={t('dns.zone.forwardToExtra')}>
|
||||||
|
<Input placeholder="10.0.0.53, 8.8.8.8" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.zone.description')} name="description">
|
||||||
|
<Input.TextArea rows={2} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<RecordsDrawer zone={recordsZone} onClose={() => setRecordsZone(null)} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Records drawer ────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface RecordsDrawerProps {
|
||||||
|
zone: Zone | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function RecordsDrawer({ zone, onClose }: RecordsDrawerProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const open = zone !== null
|
||||||
|
const zoneID = zone?.id ?? 0
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['dns', 'records', zoneID],
|
||||||
|
queryFn: () => listRecords(zoneID),
|
||||||
|
enabled: open,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState<DNSRecord | null>(null)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [form] = Form.useForm<DNSRecord>()
|
||||||
|
|
||||||
|
const upsert = useMutation({
|
||||||
|
mutationFn: async (v: DNSRecord) => {
|
||||||
|
if (editing) return (await apiClient.put(`/dns/records/${editing.id}`, v)).data
|
||||||
|
return (await apiClient.post(`/dns/zones/${zoneID}/records`, v)).data
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
setEditing(null); setCreating(false); form.resetFields()
|
||||||
|
void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
const del = useMutation({
|
||||||
|
mutationFn: async (id: number) => { await apiClient.delete(`/dns/records/${id}`) },
|
||||||
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['dns', 'records', zoneID] }) },
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const cols: ColumnsType<DNSRecord> = [
|
||||||
|
{ title: t('dns.record.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
|
||||||
|
{ title: t('dns.record.type'), dataIndex: 'record_type', key: 'record_type', render: (s: string) => <Tag>{s}</Tag> },
|
||||||
|
{ title: t('dns.record.value'), dataIndex: 'value', key: 'value', render: (s: string) => <Text code style={{ fontSize: 12 }}>{s}</Text> },
|
||||||
|
{ title: t('dns.record.ttl'), dataIndex: 'ttl', key: 'ttl', width: 80 },
|
||||||
|
{ title: t('common.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => <StatusDot active={v} /> },
|
||||||
|
{
|
||||||
|
title: t('common.actions'), key: 'actions',
|
||||||
|
render: (_, row) => (
|
||||||
|
<ActionButtons
|
||||||
|
onEdit={() => {
|
||||||
|
setEditing(row)
|
||||||
|
form.setFieldsValue(row)
|
||||||
|
}}
|
||||||
|
onDelete={() => del.mutate(row.id)}
|
||||||
|
deleteConfirm={t('dns.record.deleteConfirm', { name: row.name })}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Drawer
|
||||||
|
open={open}
|
||||||
|
onClose={onClose}
|
||||||
|
width={920}
|
||||||
|
title={zone && (
|
||||||
|
<Space>
|
||||||
|
<span>{t('dns.record.drawerTitle')}</span>
|
||||||
|
<Tag color="blue"><code>{zone.name}</code></Tag>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<DataTable
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
dataSource={data ?? []}
|
||||||
|
columns={cols}
|
||||||
|
extraActions={
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
|
||||||
|
setCreating(true); form.resetFields()
|
||||||
|
form.setFieldsValue({ record_type: 'A', ttl: 300, active: true } as DNSRecord)
|
||||||
|
}}>
|
||||||
|
{t('dns.record.add')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={editing ? t('dns.record.edit') : t('dns.record.add')}
|
||||||
|
open={editing !== null || creating}
|
||||||
|
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
|
||||||
|
onOk={() => { void form.submit() }}
|
||||||
|
confirmLoading={upsert.isPending}
|
||||||
|
width={580}
|
||||||
|
destroyOnClose
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
|
||||||
|
<Form.Item label={t('dns.record.name')} name="name" rules={[{ required: true }]}
|
||||||
|
extra={t('dns.record.nameExtra')}>
|
||||||
|
<Input placeholder="mailcow oder mailcow.internal.netcell-it.de." />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.record.type')} name="record_type" rules={[{ required: true }]}>
|
||||||
|
<Select options={RECORD_TYPES.map(r => ({ value: r, label: r }))} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.record.value')} name="value" rules={[{ required: true }]}
|
||||||
|
extra={t('dns.record.valueExtra')}>
|
||||||
|
<Input placeholder="10.10.20.5" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.record.ttl')} name="ttl">
|
||||||
|
<InputNumber min={30} max={86400} style={{ width: '100%' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('common.active')} name="active" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings tab ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function SettingsTab() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const { data, isLoading } = useQuery({ queryKey: ['dns', 'settings'], queryFn: getSettings })
|
||||||
|
const [form] = Form.useForm<Settings>()
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async (v: Settings) => (await apiClient.put('/dns/settings', v)).data,
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success(t('common.save'))
|
||||||
|
void qc.invalidateQueries({ queryKey: ['dns', 'settings'] })
|
||||||
|
},
|
||||||
|
onError: (e: Error) => message.error(e.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) return null
|
||||||
|
return (
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
initialValues={data ?? undefined}
|
||||||
|
onFinish={(v) => save.mutate(v)}
|
||||||
|
style={{ maxWidth: 720 }}
|
||||||
|
>
|
||||||
|
<Alert type="info" showIcon className="mb-12" message={t('dns.settings.intro')} />
|
||||||
|
<Form.Item label={t('dns.settings.listenAddresses')} name="listen_addresses" rules={[{ required: true }]}
|
||||||
|
extra={t('dns.settings.listenAddressesExtra')}>
|
||||||
|
<Input placeholder="127.0.0.1, ::1, 10.10.20.3" />
|
||||||
|
</Form.Item>
|
||||||
|
<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')}>
|
||||||
|
<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')}>
|
||||||
|
<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">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
|
<Space>
|
||||||
|
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl">
|
||||||
|
<InputNumber min={0} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('dns.settings.cacheMax')} name="cache_max_ttl">
|
||||||
|
<InputNumber min={60} style={{ width: 120 }} />
|
||||||
|
</Form.Item>
|
||||||
|
</Space>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||||
|
{t('common.save')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,10 +20,17 @@ case "$1" in
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Directories ──────────────────────────────────────────────
|
# ── Directories ──────────────────────────────────────────────
|
||||||
for d in /etc/edgeguard /var/lib/edgeguard /var/log/edgeguard \
|
# /etc/edgeguard und Service-Subdirs müssen für die Service-User
|
||||||
/etc/edgeguard/haproxy /etc/edgeguard/squid \
|
# (squid, unbound, haproxy laufen NICHT als edgeguard) traversier-
|
||||||
|
# bzw lesbar sein. 0755 statt 0750 — kein Geheimnis ist hier
|
||||||
|
# gespeichert, alles sind Renderer-Outputs.
|
||||||
|
for d in /etc/edgeguard /etc/edgeguard/haproxy /etc/edgeguard/squid \
|
||||||
/etc/edgeguard/wireguard /etc/edgeguard/unbound \
|
/etc/edgeguard/wireguard /etc/edgeguard/unbound \
|
||||||
/etc/edgeguard/nftables.d /etc/edgeguard/tls \
|
/etc/edgeguard/nftables.d; do
|
||||||
|
install -d -m 0755 -o "$EG_USER" -g "$EG_USER" "$d"
|
||||||
|
done
|
||||||
|
# Sensitive Verzeichnisse bleiben 0750 (TLS-Keys, ACME-State).
|
||||||
|
for d in /etc/edgeguard/tls /var/lib/edgeguard /var/log/edgeguard \
|
||||||
/var/lib/edgeguard/acme; do
|
/var/lib/edgeguard/acme; do
|
||||||
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
|
install -d -m 0750 -o "$EG_USER" -g "$EG_USER" "$d"
|
||||||
done
|
done
|
||||||
@@ -49,7 +56,38 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show all dump
|
|||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show *
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/wg show *
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload squid.service
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload squid.service
|
||||||
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload squid.service
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload squid.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload unbound.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload unbound.service
|
||||||
SUDOERS
|
SUDOERS
|
||||||
|
|
||||||
|
# ── Distro-Conf-Includes für die per-Service Renderer ─────────
|
||||||
|
# Squid + Unbound lesen ihre Distro-Default-Conf, die wir per
|
||||||
|
# Symlink/Drop-in auf unsere managed conf zeigen lassen müssen.
|
||||||
|
# Renderer können das nicht selbst (kein Schreibrecht in /etc/squid
|
||||||
|
# bzw. /etc/unbound/unbound.conf.d/), daher hier einmalig.
|
||||||
|
# Plus: Squid + Unbound laufen als eigene User (squid/unbound),
|
||||||
|
# nicht edgeguard. Damit sie unsere Conf lesen können, müssen
|
||||||
|
# die Conf-Dirs world-readable sein (Configs ohne Secrets).
|
||||||
|
install -d -m 0755 -o "$EG_USER" -g "$EG_USER" /etc/edgeguard/squid /etc/edgeguard/unbound
|
||||||
|
# Squid: ersetzt die Distro-Datei durch Symlink (Backup .distro-bak)
|
||||||
|
if [ -f /etc/squid/squid.conf ] && [ ! -L /etc/squid/squid.conf ]; then
|
||||||
|
mv /etc/squid/squid.conf /etc/squid/squid.conf.distro-bak
|
||||||
|
fi
|
||||||
|
ln -sfn /etc/edgeguard/squid/squid.conf /etc/squid/squid.conf
|
||||||
|
# Unbound: Drop-in im conf.d-Verzeichnis. Wir schreiben direkt
|
||||||
|
# rein (statt /etc/edgeguard/unbound/...) weil das AppArmor-
|
||||||
|
# Profil unbound nur /etc/unbound erlaubt. Datei gehört dem
|
||||||
|
# edgeguard-User damit der Renderer sie überschreiben kann.
|
||||||
|
install -d /etc/unbound/unbound.conf.d
|
||||||
|
# Vorgänger-Symlink (aus früheren Versionen) wegräumen.
|
||||||
|
if [ -L /etc/unbound/unbound.conf.d/edgeguard.conf ]; then
|
||||||
|
rm /etc/unbound/unbound.conf.d/edgeguard.conf
|
||||||
|
fi
|
||||||
|
if [ ! -f /etc/unbound/unbound.conf.d/edgeguard.conf ]; then
|
||||||
|
: > /etc/unbound/unbound.conf.d/edgeguard.conf
|
||||||
|
fi
|
||||||
|
chown "$EG_USER":"$EG_USER" /etc/unbound/unbound.conf.d/edgeguard.conf
|
||||||
|
chmod 0644 /etc/unbound/unbound.conf.d/edgeguard.conf
|
||||||
chmod 0440 /etc/sudoers.d/edgeguard
|
chmod 0440 /etc/sudoers.d/edgeguard
|
||||||
|
|
||||||
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──
|
# ── Sysctl-Profil für Edge-Gateway (NAT + HAProxy + Forwarding) ──
|
||||||
|
|||||||
Reference in New Issue
Block a user