diff --git a/VERSION b/VERSION index 56d0dad..f133985 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.48 +1.0.49 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 3fbba8c..2da3c32 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -32,6 +32,7 @@ import ( "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/backends" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers" 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/firewall" @@ -47,7 +48,7 @@ import ( wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.48" +var version = "1.0.49" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -135,6 +136,7 @@ func main() { auditRepo := audit.New(pool) domainsRepo := domains.New(pool) backendsRepo := backends.New(pool) + backendServersRepo := backendservers.New(pool) routingRepo := routingrules.New(pool) ifsRepo := networkifs.New(pool) ipsRepo := ipaddresses.New(pool) @@ -174,6 +176,7 @@ func main() { authed.Use(requireAuth) handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID, haproxyReloader).Register(authed) + handlers.NewBackendServersHandler(backendServersRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID, haproxyReloader).Register(authed) handlers.NewNetworksHandler(ifsRepo, ipsRepo, fwZones, auditRepo, nodeID).Register(authed) handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 4fdee7b..90adb34 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.48" +var version = "1.0.49" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index a934527..200ee35 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -24,7 +24,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.48" +var version = "1.0.49" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/internal/database/migrations/0016_backend_servers.sql b/internal/database/migrations/0016_backend_servers.sql new file mode 100644 index 0000000..585a3f9 --- /dev/null +++ b/internal/database/migrations/0016_backend_servers.sql @@ -0,0 +1,81 @@ +-- +goose Up +-- +goose StatementBegin + +-- v1.0.49: backends wird vom Single-Server-Endpoint zum Pool. +-- Bisher: backends.address/port → genau ein Upstream. +-- Jetzt: backends = Pool-Definition (LB-Algo, Healthcheck), die +-- konkreten Upstreams leben in backend_servers (1:N). +-- Damit kann eine Domain → ein Backend → N Server (HAProxy macht +-- den Loadbalance laut backends.lb_algorithm). + +CREATE TABLE IF NOT EXISTS backend_servers ( + id BIGSERIAL PRIMARY KEY, + backend_id BIGINT NOT NULL REFERENCES backends(id) ON DELETE CASCADE, + name TEXT NOT NULL, + address TEXT NOT NULL, + port INTEGER NOT NULL, + weight INTEGER NOT NULL DEFAULT 100, + backup BOOLEAN NOT NULL DEFAULT FALSE, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT backend_servers_name_unique UNIQUE (backend_id, name), + CONSTRAINT backend_servers_port_check CHECK (port > 0 AND port < 65536), + CONSTRAINT backend_servers_weight_check CHECK (weight >= 0 AND weight <= 256) +); + +CREATE INDEX IF NOT EXISTS idx_backend_servers_backend + ON backend_servers (backend_id); +CREATE INDEX IF NOT EXISTS idx_backend_servers_active + ON backend_servers (backend_id, active) WHERE active; + +-- LB-Algorithmus pro Backend. Default roundrobin entspricht haproxy- +-- default; source = sticky-per-Source-IP (für stateful Apps ohne +-- shared session store). +ALTER TABLE backends + ADD COLUMN IF NOT EXISTS lb_algorithm TEXT NOT NULL DEFAULT 'roundrobin'; + +ALTER TABLE backends + DROP CONSTRAINT IF EXISTS backends_lb_check; +ALTER TABLE backends + ADD CONSTRAINT backends_lb_check + CHECK (lb_algorithm IN ('roundrobin', 'leastconn', 'source')); + +-- Daten-Migration: jede bisherige backends-Row produziert genau eine +-- backend_servers-Row mit demselben address/port. name = backends.name +-- damit der HAProxy-server-id stabil bleibt (haproxy stats / logs +-- referenzieren ihn). Idempotent — bei Re-Apply gibt's einen Skip +-- über das Unique-Constraint. +INSERT INTO backend_servers (backend_id, name, address, port, active) +SELECT id, name, address, port, active + FROM backends + WHERE address IS NOT NULL AND port IS NOT NULL +ON CONFLICT (backend_id, name) DO NOTHING; + +-- Erst nach erfolgreichem Insert die alten Spalten droppen. Wenn die +-- Migration mittendrin abbricht, sind die Daten in beiden Tabellen +-- vorhanden — wir verlieren nichts. +ALTER TABLE backends DROP COLUMN IF EXISTS address; +ALTER TABLE backends DROP COLUMN IF EXISTS port; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +ALTER TABLE backends ADD COLUMN IF NOT EXISTS address TEXT; +ALTER TABLE backends ADD COLUMN IF NOT EXISTS port INTEGER; + +UPDATE backends b + SET address = bs.address, + port = bs.port + FROM ( + SELECT DISTINCT ON (backend_id) backend_id, address, port + FROM backend_servers + ORDER BY backend_id, id ASC + ) bs + WHERE b.id = bs.backend_id; + +ALTER TABLE backends DROP CONSTRAINT IF EXISTS backends_lb_check; +ALTER TABLE backends DROP COLUMN IF EXISTS lb_algorithm; +DROP TABLE IF EXISTS backend_servers; +-- +goose StatementEnd diff --git a/internal/handlers/backend_servers.go b/internal/handlers/backend_servers.go new file mode 100644 index 0000000..0364aeb --- /dev/null +++ b/internal/handlers/backend_servers.go @@ -0,0 +1,126 @@ +package handlers + +import ( + "context" + "errors" + "log/slog" + "strconv" + + "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" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers" +) + +// BackendServersHandler exposes: +// GET /api/v1/backends/:id/servers +// POST /api/v1/backends/:id/servers +// PUT /api/v1/backend-servers/:id +// DELETE /api/v1/backend-servers/:id +type BackendServersHandler struct { + Repo *backendservers.Repo + Audit *audit.Repo + NodeID string + Reloader func(ctx context.Context) error +} + +func NewBackendServersHandler(repo *backendservers.Repo, a *audit.Repo, + nodeID string, reloader func(context.Context) error) *BackendServersHandler { + return &BackendServersHandler{Repo: repo, Audit: a, NodeID: nodeID, Reloader: reloader} +} + +func (h *BackendServersHandler) reload(ctx context.Context, op string) { + if h.Reloader == nil { + return + } + if err := h.Reloader(ctx); err != nil { + slog.Warn("haproxy: reload after server mutation failed", "op", op, "error", err) + } +} + +func (h *BackendServersHandler) Register(rg *gin.RouterGroup) { + rg.GET("/backends/:id/servers", h.ListForBackend) + rg.POST("/backends/:id/servers", h.Create) + g := rg.Group("/backend-servers") + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) +} + +func (h *BackendServersHandler) ListForBackend(c *gin.Context) { + backendID, ok := parseID(c) + if !ok { + return + } + out, err := h.Repo.ListByBackend(c.Request.Context(), backendID) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"servers": out}) +} + +func (h *BackendServersHandler) Create(c *gin.Context) { + backendID, ok := parseID(c) + if !ok { + return + } + var req models.BackendServer + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + req.BackendID = backendID + out, err := h.Repo.Create(c.Request.Context(), req) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.server.create", req.Name, out, h.NodeID) + response.Created(c, out) + h.reload(c.Request.Context(), "create") +} + +func (h *BackendServersHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req models.BackendServer + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + out, err := h.Repo.Update(c.Request.Context(), id, req) + if err != nil { + if errors.Is(err, backendservers.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.server.update", out.Name, out, h.NodeID) + response.OK(c, out) + h.reload(c.Request.Context(), "update") +} + +func (h *BackendServersHandler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.Repo.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, backendservers.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "backend.server.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) + h.reload(c.Request.Context(), "delete") +} diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index fde221e..f8996e0 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -83,8 +83,15 @@ frontend internal_stats backend api_backend server api1 127.0.0.1:9443 check -{{- range .Backends}} +{{- range $b := .Backends}} -backend eg_backend_{{.ID}} - server {{.Name | safeID}} {{.Address}}:{{.Port}}{{if eq .Scheme "https"}} ssl verify none alpn h2,http/1.1{{end}}{{if .HealthCheckPath}} check inter 5s{{end}} +backend eg_backend_{{$b.ID}} + balance {{$b.LBAlgorithm}} + {{- if $b.HealthCheckPath}} + option httpchk + http-check send meth GET uri {{$b.HealthCheckPath}} + {{- end}} + {{- range $s := $b.Servers}} + server {{$s.Name | safeID}} {{$s.Address}}:{{$s.Port}}{{if eq $b.Scheme "https"}} ssl verify none alpn h2,http/1.1{{end}}{{if $b.HealthCheckPath}} check inter 5s{{end}} weight {{$s.Weight}}{{if $s.Backup}} backup{{end}} + {{- end}} {{- end}} diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go index 44891e5..96c51a0 100644 --- a/internal/haproxy/haproxy.go +++ b/internal/haproxy/haproxy.go @@ -19,6 +19,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" "git.netcell-it.de/projekte/edgeguard-native/internal/models" "git.netcell-it.de/projekte/edgeguard-native/internal/services/backends" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/backendservers" "git.netcell-it.de/projekte/edgeguard-native/internal/services/domains" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" ) @@ -55,10 +56,11 @@ var tpl = template.Must(template.New("haproxy").Funcs(template.FuncMap{ }).Parse(cfgTpl)) type Generator struct { - Pool *pgxpool.Pool - DomainsRepo *domains.Repo - BackendsRepo *backends.Repo - RoutingRepo *routingrules.Repo + Pool *pgxpool.Pool + DomainsRepo *domains.Repo + BackendsRepo *backends.Repo + ServersRepo *backendservers.Repo + RoutingRepo *routingrules.Repo OutputPath string SkipReload bool @@ -69,6 +71,7 @@ func New(pool *pgxpool.Pool) *Generator { Pool: pool, DomainsRepo: domains.New(pool), BackendsRepo: backends.New(pool), + ServersRepo: backendservers.New(pool), RoutingRepo: routingrules.New(pool), } } @@ -101,10 +104,12 @@ func (g *Generator) Render(ctx context.Context) error { } // View is what the template consumes. Routes per domain are pre- -// joined here so the template can stay declarative. +// joined here so the template can stay declarative; Servers leben pro +// BackendView, damit das Template einen `backend …`-Block mit den N +// Server-Zeilen rendern kann. type View struct { Domains []DomainView - Backends []models.Backend + Backends []BackendView } type DomainView struct { @@ -117,6 +122,11 @@ type RouteView struct { BackendID int64 } +type BackendView struct { + models.Backend + Servers []models.BackendServer +} + func (g *Generator) loadView(ctx context.Context) (*View, error) { doms, err := g.DomainsRepo.List(ctx) if err != nil { @@ -126,6 +136,10 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { if err != nil { return nil, fmt.Errorf("list backends: %w", err) } + srvs, err := g.ServersRepo.ListAll(ctx) + if err != nil { + return nil, fmt.Errorf("list backend servers: %w", err) + } rules, err := g.RoutingRepo.List(ctx) if err != nil { return nil, fmt.Errorf("list routing rules: %w", err) @@ -142,11 +156,23 @@ func (g *Generator) loadView(ctx context.Context) (*View, error) { }) } - activeBackends := make([]models.Backend, 0, len(bes)) - for _, b := range bes { - if b.Active { - activeBackends = append(activeBackends, b) + srvByBackend := map[int64][]models.BackendServer{} + for _, s := range srvs { + if !s.Active { + continue } + srvByBackend[s.BackendID] = append(srvByBackend[s.BackendID], s) + } + + activeBackends := make([]BackendView, 0, len(bes)) + for _, b := range bes { + if !b.Active { + continue + } + activeBackends = append(activeBackends, BackendView{ + Backend: b, + Servers: srvByBackend[b.ID], + }) } domViews := make([]DomainView, 0, len(doms)) diff --git a/internal/haproxy/haproxy_test.go b/internal/haproxy/haproxy_test.go index 81cfb66..586e59e 100644 --- a/internal/haproxy/haproxy_test.go +++ b/internal/haproxy/haproxy_test.go @@ -17,6 +17,21 @@ func renderView(t *testing.T, v View) string { return buf.String() } +func mkBackend(id int64, name string, hcp *string) models.Backend { + return models.Backend{ + ID: id, Name: name, Scheme: "http", + LBAlgorithm: "roundrobin", Active: true, + HealthCheckPath: hcp, + } +} + +func mkServer(backendID int64, name, addr string, port int) models.BackendServer { + return models.BackendServer{ + BackendID: backendID, Name: name, Address: addr, Port: port, + Weight: 100, Active: true, + } +} + func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) { out := renderView(t, View{}) for _, w := range []string{ @@ -37,9 +52,13 @@ func TestRender_BaselineHasFrontendsAndApiBackend(t *testing.T) { func TestRender_DomainRoutesEmitUseBackend(t *testing.T) { v := View{ - Backends: []models.Backend{ - {ID: 1, Name: "app", Address: "10.0.0.10", Port: 8080, Active: true}, - {ID: 2, Name: "api", Address: "10.0.0.20", Port: 9000, Active: true}, + Backends: []BackendView{ + {Backend: mkBackend(1, "app", nil), Servers: []models.BackendServer{ + mkServer(1, "app", "10.0.0.10", 8080), + }}, + {Backend: mkBackend(2, "api", nil), Servers: []models.BackendServer{ + mkServer(2, "api", "10.0.0.20", 9000), + }}, }, Domains: []DomainView{{ Domain: models.Domain{ID: 1, Name: "example.com", Active: true}, @@ -55,6 +74,7 @@ func TestRender_DomainRoutesEmitUseBackend(t *testing.T) { "server app 10.0.0.10:8080", "backend eg_backend_2", "server api 10.0.0.20:9000", + "balance roundrobin", "use_backend eg_backend_1 if { hdr(host) -i example.com } { path_beg / }", "use_backend eg_backend_2 if { hdr(host) -i example.com } { path_beg /api }", } { @@ -67,12 +87,46 @@ func TestRender_DomainRoutesEmitUseBackend(t *testing.T) { func TestRender_HealthCheckPathAddsCheckInter(t *testing.T) { hcp := "/health" v := View{ - Backends: []models.Backend{ - {ID: 1, Name: "app", Address: "10.0.0.10", Port: 8080, Active: true, HealthCheckPath: &hcp}, + Backends: []BackendView{ + {Backend: mkBackend(1, "app", &hcp), Servers: []models.BackendServer{ + mkServer(1, "app", "10.0.0.10", 8080), + }}, }, } out := renderView(t, v) if !strings.Contains(out, "server app 10.0.0.10:8080 check inter 5s") { t.Errorf("expected `check inter 5s` for backend with health_check_path:\n%s", out) } + if !strings.Contains(out, "option httpchk") { + t.Errorf("expected `option httpchk` when health_check_path set:\n%s", out) + } +} + +func TestRender_MultiServerPool(t *testing.T) { + v := View{ + Backends: []BackendView{ + { + Backend: models.Backend{ID: 1, Name: "vmm", Scheme: "http", LBAlgorithm: "leastconn", Active: true}, + Servers: []models.BackendServer{ + {BackendID: 1, Name: "vmm-1", Address: "10.0.0.11", Port: 8080, Weight: 100, Active: true}, + {BackendID: 1, Name: "vmm-2", Address: "10.0.0.12", Port: 8080, Weight: 100, Active: true}, + {BackendID: 1, Name: "vmm-3", Address: "10.0.0.13", Port: 8080, Weight: 50, Backup: true, Active: true}, + }, + }, + }, + } + out := renderView(t, v) + for _, w := range []string{ + "backend eg_backend_1", + "balance leastconn", + "server vmm-1 10.0.0.11:8080", + "server vmm-2 10.0.0.12:8080", + "server vmm-3 10.0.0.13:8080", + "weight 50", + " backup", + } { + if !strings.Contains(out, w) { + t.Errorf("missing %q in multi-server output:\n%s", w, out) + } + } } diff --git a/internal/models/backend.go b/internal/models/backend.go index e1f72ae..fe9bd4a 100644 --- a/internal/models/backend.go +++ b/internal/models/backend.go @@ -2,16 +2,35 @@ package models import "time" +// Backend ist eine Pool-Definition (Name, Scheme, Healthcheck, LB-Algo). +// Die konkreten Upstream-Server leben in BackendServer (1:N). type Backend struct { ID int64 `gorm:"primaryKey" json:"id"` Name string `gorm:"column:name;uniqueIndex" json:"name"` Scheme string `gorm:"column:scheme" json:"scheme"` - Address string `gorm:"column:address" json:"address"` - Port int `gorm:"column:port" json:"port"` HealthCheckPath *string `gorm:"column:health_check_path" json:"health_check_path,omitempty"` + LBAlgorithm string `gorm:"column:lb_algorithm" json:"lb_algorithm"` Active bool `gorm:"column:active" json:"active"` CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` } func (Backend) TableName() string { return "backends" } + +// BackendServer ist ein konkreter Upstream im Pool. weight steuert das +// LB-Verhältnis (roundrobin/leastconn); backup wird nur bedient wenn +// alle non-backup-Server down sind. +type BackendServer struct { + ID int64 `gorm:"primaryKey" json:"id"` + BackendID int64 `gorm:"column:backend_id" json:"backend_id"` + Name string `gorm:"column:name" json:"name"` + Address string `gorm:"column:address" json:"address"` + Port int `gorm:"column:port" json:"port"` + Weight int `gorm:"column:weight" json:"weight"` + Backup bool `gorm:"column:backup" json:"backup"` + Active bool `gorm:"column:active" json:"active"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (BackendServer) TableName() string { return "backend_servers" } diff --git a/internal/services/backends/backends.go b/internal/services/backends/backends.go index 5940292..12afa8e 100644 --- a/internal/services/backends/backends.go +++ b/internal/services/backends/backends.go @@ -1,4 +1,7 @@ // Package backends implements CRUD against the `backends` table. +// Ein Backend ist ein Pool — Name, Scheme, Healthcheck, LB-Algorithm. +// Die konkreten Upstream-Server liegen in backend_servers (siehe +// services/backendservers). package backends import ( @@ -20,7 +23,7 @@ type Repo struct { func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } const baseSelect = ` -SELECT id, name, scheme, address, port, health_check_path, active, +SELECT id, name, scheme, health_check_path, lb_algorithm, active, created_at, updated_at FROM backends ` @@ -55,29 +58,34 @@ func (r *Repo) Get(ctx context.Context, id int64) (*models.Backend, error) { } func (r *Repo) Create(ctx context.Context, b models.Backend) (*models.Backend, error) { + if b.LBAlgorithm == "" { + b.LBAlgorithm = "roundrobin" + } row := r.Pool.QueryRow(ctx, ` -INSERT INTO backends (name, scheme, address, port, health_check_path, active) -VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, name, scheme, address, port, health_check_path, active, +INSERT INTO backends (name, scheme, health_check_path, lb_algorithm, active) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, name, scheme, health_check_path, lb_algorithm, active, created_at, updated_at`, - b.Name, b.Scheme, b.Address, b.Port, b.HealthCheckPath, b.Active) + b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.Active) return scanBackend(row) } func (r *Repo) Update(ctx context.Context, id int64, b models.Backend) (*models.Backend, error) { + if b.LBAlgorithm == "" { + b.LBAlgorithm = "roundrobin" + } row := r.Pool.QueryRow(ctx, ` UPDATE backends SET name = $1, scheme = $2, - address = $3, - port = $4, - health_check_path = $5, - active = $6, + health_check_path = $3, + lb_algorithm = $4, + active = $5, updated_at = NOW() -WHERE id = $7 -RETURNING id, name, scheme, address, port, health_check_path, active, +WHERE id = $6 +RETURNING id, name, scheme, health_check_path, lb_algorithm, active, created_at, updated_at`, - b.Name, b.Scheme, b.Address, b.Port, b.HealthCheckPath, b.Active, id) + b.Name, b.Scheme, b.HealthCheckPath, b.LBAlgorithm, b.Active, id) out, err := scanBackend(row) if err != nil { if errors.Is(err, pgx.ErrNoRows) { @@ -102,8 +110,8 @@ func (r *Repo) Delete(ctx context.Context, id int64) error { func scanBackend(row interface{ Scan(...any) error }) (*models.Backend, error) { var b models.Backend if err := row.Scan( - &b.ID, &b.Name, &b.Scheme, &b.Address, &b.Port, - &b.HealthCheckPath, &b.Active, + &b.ID, &b.Name, &b.Scheme, + &b.HealthCheckPath, &b.LBAlgorithm, &b.Active, &b.CreatedAt, &b.UpdatedAt, ); err != nil { return nil, err diff --git a/internal/services/backendservers/backendservers.go b/internal/services/backendservers/backendservers.go new file mode 100644 index 0000000..870fb71 --- /dev/null +++ b/internal/services/backendservers/backendservers.go @@ -0,0 +1,144 @@ +// Package backendservers implements CRUD against the backend_servers +// table — die konkreten Upstream-Server pro Backend-Pool. Schema in +// migration 0016_backend_servers.sql. +package backendservers + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrNotFound = errors.New("backend server not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, backend_id, name, address, port, weight, backup, active, + created_at, updated_at +FROM backend_servers +` + +// ListByBackend liefert alle Server eines Pools. Sortierung: backup +// am Ende (HAProxy bedient sie nur wenn primaries down sind, in der +// UI sollen sie auch optisch unten stehen), dann nach name. +func (r *Repo) ListByBackend(ctx context.Context, backendID int64) ([]models.BackendServer, error) { + rows, err := r.Pool.Query(ctx, + baseSelect+" WHERE backend_id = $1 ORDER BY backup ASC, name ASC", backendID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.BackendServer, 0, 4) + for rows.Next() { + s, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *s) + } + return out, rows.Err() +} + +// ListAll gibt sämtliche Server zurück — wird vom HAProxy-Renderer +// genutzt, damit der View in einem einzigen Query alle Pools füllt. +func (r *Repo) ListAll(ctx context.Context) ([]models.BackendServer, error) { + rows, err := r.Pool.Query(ctx, + baseSelect+" ORDER BY backend_id ASC, backup ASC, name ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.BackendServer, 0, 16) + for rows.Next() { + s, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *s) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*models.BackendServer, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + s, err := scan(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return s, nil +} + +func (r *Repo) Create(ctx context.Context, s models.BackendServer) (*models.BackendServer, error) { + if s.Weight == 0 { + s.Weight = 100 + } + row := r.Pool.QueryRow(ctx, ` +INSERT INTO backend_servers (backend_id, name, address, port, weight, backup, active) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, backend_id, name, address, port, weight, backup, active, + created_at, updated_at`, + s.BackendID, s.Name, s.Address, s.Port, s.Weight, s.Backup, s.Active) + return scan(row) +} + +func (r *Repo) Update(ctx context.Context, id int64, s models.BackendServer) (*models.BackendServer, error) { + if s.Weight == 0 { + s.Weight = 100 + } + row := r.Pool.QueryRow(ctx, ` +UPDATE backend_servers SET + name = $1, + address = $2, + port = $3, + weight = $4, + backup = $5, + active = $6, + updated_at = NOW() +WHERE id = $7 +RETURNING id, backend_id, name, address, port, weight, backup, active, + created_at, updated_at`, + s.Name, s.Address, s.Port, s.Weight, s.Backup, s.Active, id) + out, err := scan(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return out, nil +} + +func (r *Repo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM backend_servers WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func scan(row interface{ Scan(...any) error }) (*models.BackendServer, error) { + var s models.BackendServer + if err := row.Scan( + &s.ID, &s.BackendID, &s.Name, &s.Address, &s.Port, + &s.Weight, &s.Backup, &s.Active, + &s.CreatedAt, &s.UpdatedAt, + ); err != nil { + return nil, err + } + return &s, nil +} diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index 353195c..8cee556 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -77,7 +77,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.48' +const VERSION = '1.0.49' export default function Sidebar({ isOpen, onClose }: SidebarProps) { const { t } = useTranslation() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 23fc9fb..d2149b8 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -202,13 +202,11 @@ }, "backends": { "title": "Backends", - "intro": "Upstream-Server, an die HAProxy weiterroutet. Health-Check-Pfad optional aktiviert TCP+HTTP-Probes alle 5s.", - "addBackend": "Backend hinzufügen", - "editBackend": "Backend bearbeiten", + "intro": "Upstream-Pools (Backend = N Server). HAProxy verteilt laut LB-Algorithmus; Health-Check-Pfad aktiviert HTTP-Probes alle 5s pro Server.", + "addBackend": "Backend-Pool hinzufügen", + "editBackend": "Backend-Pool bearbeiten", "name": "Name", "scheme": "Schema", - "address": "Adresse", - "port": "Port", "target": "Ziel", "healthCheck": "Health-Check-Pfad", "active": "Aktiv", @@ -217,8 +215,30 @@ "attachedDomains": "Domains", "attachedDomainsHint": "Domains, die dieses Backend als Primary verwenden. Auswahl umkonfiguriert die Domains direkt — gleiche Quelle wie der Backend-Picker im Domain-Modal.", "selectDomains": "Domains wählen", + "lbAlgo": "Load-Balancing", + "lbAlgoHint": "roundrobin = gleichmäßig, leastconn = an den Server mit wenigsten Verbindungen, source = sticky per Client-IP (für stateful Apps ohne shared session).", + "servers": "Server", + "noServers": "kein Server", + "nServers": "{{n}} Server", + "serversIn": "Server in „{{name}}\"", + "serverHintCreate": "Speichern legt nur den Pool an. Server kommen im nächsten Schritt — Pool öffnen → „Server hinzufügen\".", "actions": "Aktionen", - "deleteConfirm": "Backend {{name}} wirklich löschen?" + "deleteConfirm": "Backend-Pool {{name}} wirklich löschen? Alle Server im Pool werden mitentfernt.", + "server": { + "intro": "Upstream-Server im Pool. Reihenfolge in HAProxy egal — der LB-Algorithmus entscheidet.", + "add": "Server hinzufügen", + "edit": "Server bearbeiten", + "name": "Server-Name", + "address": "Adresse", + "port": "Port", + "target": "Endpoint", + "weight": "Gewicht", + "weightHint": "0–256. Höher = mehr Traffic. 100 = Standard.", + "backup": "Backup", + "backupHint": "Backup-Server werden nur angesprochen, wenn alle primären Server (non-backup) down sind.", + "empty": "Noch keine Server im Pool. „Server hinzufügen\" startet damit.", + "deleteConfirm": "Server {{name}} wirklich löschen?" + } }, "routing": { "title": "Routing-Regeln", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 707f41f..06fcd43 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -202,13 +202,11 @@ }, "backends": { "title": "Backends", - "intro": "Upstream servers HAProxy proxies to. Optional health-check path enables TCP + HTTP probes every 5s.", - "addBackend": "Add backend", - "editBackend": "Edit backend", + "intro": "Upstream pools (one backend = N servers). HAProxy balances load by the chosen algorithm; health-check path enables HTTP probes every 5s per server.", + "addBackend": "Add backend pool", + "editBackend": "Edit backend pool", "name": "Name", "scheme": "Scheme", - "address": "Address", - "port": "Port", "target": "Target", "healthCheck": "Health check path", "active": "Active", @@ -217,8 +215,30 @@ "attachedDomains": "Domains", "attachedDomainsHint": "Domains that use this backend as their primary. Selecting domains here reconfigures them directly — same source of truth as the Domain modal's backend picker.", "selectDomains": "Select domains", + "lbAlgo": "Load balancing", + "lbAlgoHint": "roundrobin = evenly, leastconn = pick the server with fewest active connections, source = sticky per client-IP hash (for stateful apps without shared session).", + "servers": "Servers", + "noServers": "no server", + "nServers": "{{n}} servers", + "serversIn": "Servers in “{{name}}”", + "serverHintCreate": "Saving creates the pool only. Add servers in the next step — open the pool and click “Add server”.", "actions": "Actions", - "deleteConfirm": "Really delete backend {{name}}?" + "deleteConfirm": "Really delete backend pool {{name}}? All servers in the pool will be removed too.", + "server": { + "intro": "Upstream servers in this pool. Order doesn't matter — the LB algorithm decides.", + "add": "Add server", + "edit": "Edit server", + "name": "Server name", + "address": "Address", + "port": "Port", + "target": "Endpoint", + "weight": "Weight", + "weightHint": "0–256. Higher = more traffic. 100 = default.", + "backup": "Backup", + "backupHint": "Backup servers receive traffic only when every primary (non-backup) server is down.", + "empty": "No servers in the pool yet. Click “Add server” to get started.", + "deleteConfirm": "Really delete server {{name}}?" + } }, "routing": { "title": "Routing rules", diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index 0884b3b..9640559 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -1,5 +1,8 @@ import { useState } from 'react' -import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tag, message } from 'antd' +import { + Alert, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, + Select, Space, Switch, Table, Tag, Typography, message, +} from 'antd' import type { ColumnsType } from 'antd/es/table' import { DatabaseOutlined, PlusOutlined } from '@ant-design/icons' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -11,39 +14,60 @@ import StatusDot from '../../components/StatusDot' import apiClient, { isEnvelope } from '../../api/client' +const { Text } = Typography + interface Backend { id: number name: string scheme: string - address: string - port: number health_check_path?: string | null + lb_algorithm: 'roundrobin' | 'leastconn' | 'source' active: boolean created_at: string updated_at: string } +interface BackendServer { + id: number + backend_id: number + name: string + address: string + port: number + weight: number + backup: boolean + active: boolean +} + interface BackendFormValues { name: string scheme: 'http' | 'https' - address: string - port: number health_check_path?: string + lb_algorithm: 'roundrobin' | 'leastconn' | 'source' active: boolean domain_ids?: number[] } +interface ServerFormValues { + name: string + address: string + port: number + weight: number + backup: boolean + active: boolean +} + async function listBackends(): Promise { const r = await apiClient.get('/backends') if (!isEnvelope(r.data)) return [] - const payload = r.data.data as { backends?: Backend[] } - return payload.backends ?? [] + return (r.data.data as { backends?: Backend[] }).backends ?? [] +} + +async function listServers(backendID: number): Promise { + const r = await apiClient.get(`/backends/${backendID}/servers`) + if (!isEnvelope(r.data)) return [] + return (r.data.data as { servers?: BackendServer[] }).servers ?? [] } -// DomainFull mirrors the API contract; we need the full body to PUT -// the domain back when re-attaching/detaching it (the handler does -// a full-row replace, not a patch). Fields beyond what the form -// shows are passed through verbatim. interface DomainFull { id: number name: string @@ -52,8 +76,6 @@ interface DomainFull { http_to_https: boolean hsts_enabled: boolean notes?: string | null - created_at?: string - updated_at?: string } async function listDomains(): Promise { const r = await apiClient.get('/domains') @@ -68,10 +90,20 @@ export default function BackendsPage() { const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends }) const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains }) - // Reverse-lookup: which domains have this backend as primary? - // Read-only — domain ↔ backend coupling is owned by the Domains - // page, but showing it here makes the connection bi-directional - // in the UI. + // server-counts pro Backend laden wir lazy bei Expansion; in der + // Tabelle reicht ein Hinweis ob 0 / N Server. + const { data: serverCounts } = useQuery({ + queryKey: ['backend-server-counts', (data ?? []).map(b => b.id).join(',')], + queryFn: async () => { + const out: Record = {} + await Promise.all((data ?? []).map(async (b) => { + out[b.id] = (await listServers(b.id)).length + })) + return out + }, + enabled: !!data && data.length > 0, + }) + const domainsForBackend = (id: number) => (domains ?? []).filter(d => d.primary_backend_id === id) @@ -79,12 +111,6 @@ export default function BackendsPage() { const [creating, setCreating] = useState(false) const [form] = Form.useForm() - // syncDomainAttachments diff-applies the multi-select to the - // domains table — domains that should now point at this backend - // get a PUT with primary_backend_id=backend.id; domains that used - // to point at it but were unchecked get a PUT with null. - // Domains are kept canonical in their own table; this is just the - // reverse-edit affordance the operator asked for. async function syncDomainAttachments(backendID: number, selected: number[]) { const all = domains ?? [] const wasAttached = new Set(all.filter(d => d.primary_backend_id === backendID).map(d => d.id)) @@ -157,8 +183,17 @@ export default function BackendsPage() { { title: t('backends.name'), dataIndex: 'name', key: 'name' }, { title: t('backends.scheme'), dataIndex: 'scheme', key: 'scheme' }, { - title: t('backends.target'), key: 'target', - render: (_, row) => `${row.address}:${row.port}`, + title: t('backends.servers'), key: 'srvcount', + render: (_, row) => { + const n = serverCounts?.[row.id] ?? 0 + return n === 0 + ? {t('backends.noServers')} + : {t('backends.nServers', { n })} + }, + }, + { + title: t('backends.lbAlgo'), dataIndex: 'lb_algorithm', key: 'lb', + render: (v: string) => {v}, }, { title: t('backends.healthCheck'), dataIndex: 'health_check_path', key: 'hc', render: (v?: string) => v ?? '—' }, { @@ -179,9 +214,8 @@ export default function BackendsPage() { form.setFieldsValue({ name: row.name, scheme: row.scheme as 'http' | 'https', - address: row.address, - port: row.port, health_check_path: row.health_check_path ?? undefined, + lb_algorithm: row.lb_algorithm, active: row.active, domain_ids: domainsForBackend(row.id).map(d => d.id), }) @@ -205,10 +239,14 @@ export default function BackendsPage() { loading={isLoading} dataSource={data ?? []} columns={columns} + expandable={{ + expandedRowRender: (record) => , + rowExpandable: (record) => !!record.id, + }} extraActions={ @@ -220,7 +258,15 @@ export default function BackendsPage() { onCancel={() => { setEditing(null); setCreating(false) }} onOk={() => { void form.submit() }} confirmLoading={create.isPending || update.isPending} + width={640} > + {creating && ( + + )}
- + - - - + + @@ -267,6 +317,147 @@ export default function BackendsPage() { /> + {editing && ( + + + + )} + + + ) +} + +// ServerPanel ist sowohl das Expand-Panel in der Tabelle als auch der +// Server-Editor im Backend-Edit-Modal. Eigene Mutation/Query damit Edit +// und Expand unabhängig voneinander einklappen können. +function ServerPanel({ backendID }: { backendID: number }) { + const { t } = useTranslation() + const qc = useQueryClient() + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form] = Form.useForm() + + const { data: servers, isLoading } = useQuery({ + queryKey: ['backend-servers', backendID], + queryFn: () => listServers(backendID), + }) + + const create = useMutation({ + mutationFn: async (v: ServerFormValues) => { + await apiClient.post(`/backends/${backendID}/servers`, v) + }, + onSuccess: () => { + message.success(t('common.save')) + setOpen(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) + void qc.invalidateQueries({ queryKey: ['backend-server-counts'] }) + }, + onError: (e: Error) => message.error(e.message), + }) + + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: ServerFormValues }) => { + await apiClient.put(`/backend-servers/${id}`, { ...v, backend_id: backendID }) + }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); form.resetFields() + void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) + }, + onError: (e: Error) => message.error(e.message), + }) + + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/backend-servers/${id}`) }, + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['backend-servers', backendID] }) + void qc.invalidateQueries({ queryKey: ['backend-server-counts'] }) + }, + onError: (e: Error) => message.error(e.message), + }) + + const cols: ColumnsType = [ + { title: t('backends.server.name'), dataIndex: 'name' }, + { + title: t('backends.server.target'), key: 'tgt', + render: (_, r) => {r.address}:{r.port}, + }, + { title: t('backends.server.weight'), dataIndex: 'weight', width: 80 }, + { title: t('backends.server.backup'), dataIndex: 'backup', + render: (v: boolean) => v ? Backup : '—', width: 100 }, + { title: t('backends.active'), dataIndex: 'active', + render: (v: boolean) => , width: 80 }, + { + title: t('common.actions'), key: 'a', width: 160, + render: (_, r) => ( + + + del.mutate(r.id)}> + + + + ), + }, + ] + + return ( +
+
+ {t('backends.server.intro')} + +
+ + { setOpen(false); setEditing(null); form.resetFields() }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + destroyOnHidden + > +
editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}> + + + + + + + + + + + + + + + + + + +
)