feat(backends): Pool-Modell — Backend = Pool, N Server pro Backend

Migration 0016: backend_servers (id, backend_id, name, address, port,
weight, backup, active) + backends.lb_algorithm. Daten-Migration kopiert
bestehende backends.address/port als ersten Server, dann DROP COLUMN.

HAProxy-Renderer: rendert pro Backend einen Block mit `balance <algo>`
+ N `server`-Zeilen (weight, backup-Flag, optional check inter 5s).
LB-Algorithmen: roundrobin / leastconn / source.

REST: /backends/:id/servers (GET/POST), /backend-servers/:id (PUT/DELETE).
Re-rendert HAProxy nach jeder Server-Mutation.

UI: address/port aus Backend-Form raus, lb_algorithm-Select rein. Server
verwaltet ein expandable Sub-Panel pro Backend-Row (Tabelle + Add/Edit/
Delete-Modal). Domain-Attachment-Multi-Select bleibt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-11 20:55:47 +02:00
parent 05850934fb
commit 8aac24b566
16 changed files with 784 additions and 85 deletions

View File

@@ -1 +1 @@
1.0.48
1.0.49

View File

@@ -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)

View File

@@ -9,7 +9,7 @@ import (
"os"
)
var version = "1.0.48"
var version = "1.0.49"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -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.

View File

@@ -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

View File

@@ -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")
}

View File

@@ -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}}

View File

@@ -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))

View File

@@ -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)
}
}
}

View File

@@ -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" }

View File

@@ -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

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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": "0256. 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",

View File

@@ -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": "0256. 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",

View File

@@ -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<Backend[]> {
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<BackendServer[]> {
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<DomainFull[]> {
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<number, number> = {}
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<BackendFormValues>()
// 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
? <Tag color="orange">{t('backends.noServers')}</Tag>
: <Tag color="blue">{t('backends.nServers', { n })}</Tag>
},
},
{
title: t('backends.lbAlgo'), dataIndex: 'lb_algorithm', key: 'lb',
render: (v: string) => <Tag>{v}</Tag>,
},
{ 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) => <ServerPanel backendID={record.id} />,
rowExpandable: (record) => !!record.id,
}}
extraActions={
<Button type="primary" icon={<PlusOutlined />} onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({ scheme: 'http', port: 8080, active: true })
form.setFieldsValue({ scheme: 'http', lb_algorithm: 'roundrobin', active: true })
}}>
{t('backends.addBackend')}
</Button>
@@ -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 && (
<Alert
type="info" showIcon
message={t('backends.serverHintCreate')}
className="mb-16"
/>
)}
<Form
form={form}
layout="vertical"
@@ -230,16 +276,20 @@ export default function BackendsPage() {
}}
>
<Form.Item label={t('backends.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="upstream-app" />
<Input placeholder="vmm-pool" />
</Form.Item>
<Form.Item label={t('backends.scheme')} name="scheme" rules={[{ required: true }]}>
<Select options={[{ value: 'http', label: 'http' }, { value: 'https', label: 'https' }]} />
</Form.Item>
<Form.Item label={t('backends.address')} name="address" rules={[{ required: true }]}>
<Input placeholder="10.0.0.10" />
</Form.Item>
<Form.Item label={t('backends.port')} name="port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
<Form.Item label={t('backends.lbAlgo')} name="lb_algorithm" rules={[{ required: true }]}
extra={t('backends.lbAlgoHint')}>
<Select
options={[
{ value: 'roundrobin', label: 'roundrobin — gleichmäßige Verteilung' },
{ value: 'leastconn', label: 'leastconn — wenigste aktive Verbindungen' },
{ value: 'source', label: 'source — sticky per Source-IP-Hash' },
]}
/>
</Form.Item>
<Form.Item label={t('backends.healthCheck')} name="health_check_path">
<Input placeholder="/health" />
@@ -267,6 +317,147 @@ export default function BackendsPage() {
/>
</Form.Item>
</Form>
{editing && (
<Card size="small" title={t('backends.serversIn', { name: editing.name })} className="mt-16">
<ServerPanel backendID={editing.id} />
</Card>
)}
</Modal>
</div>
)
}
// 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<BackendServer | null>(null)
const [form] = Form.useForm<ServerFormValues>()
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<BackendServer> = [
{ title: t('backends.server.name'), dataIndex: 'name' },
{
title: t('backends.server.target'), key: 'tgt',
render: (_, r) => <Text code>{r.address}:{r.port}</Text>,
},
{ title: t('backends.server.weight'), dataIndex: 'weight', width: 80 },
{ title: t('backends.server.backup'), dataIndex: 'backup',
render: (v: boolean) => v ? <Tag color="purple">Backup</Tag> : '—', width: 100 },
{ title: t('backends.active'), dataIndex: 'active',
render: (v: boolean) => <StatusDot active={v} />, width: 80 },
{
title: t('common.actions'), key: 'a', width: 160,
render: (_, r) => (
<Space size={4}>
<Button size="small" onClick={() => {
setEditing(r)
form.setFieldsValue({
name: r.name, address: r.address, port: r.port,
weight: r.weight, backup: r.backup, active: r.active,
})
}}>{t('common.edit')}</Button>
<Popconfirm title={t('backends.server.deleteConfirm', { name: r.name })}
onConfirm={() => del.mutate(r.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>
</Space>
),
},
]
return (
<div>
<div className="mb-8" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary">{t('backends.server.intro')}</Text>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => {
setOpen(true)
form.resetFields()
form.setFieldsValue({ weight: 100, backup: false, active: true, port: 8080 })
}}>
{t('backends.server.add')}
</Button>
</div>
<Table
size="small"
rowKey="id"
loading={isLoading}
dataSource={servers ?? []}
columns={cols}
pagination={false}
locale={{ emptyText: t('backends.server.empty') }}
/>
<Modal
title={editing ? t('backends.server.edit') : t('backends.server.add')}
open={open || editing !== null}
onCancel={() => { setOpen(false); setEditing(null); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={create.isPending || update.isPending}
destroyOnHidden
>
<Form form={form} layout="vertical"
onFinish={(v) => editing ? update.mutate({ id: editing.id, v }) : create.mutate(v)}>
<Form.Item label={t('backends.server.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="vmm-1" />
</Form.Item>
<Form.Item label={t('backends.server.address')} name="address" rules={[{ required: true }]}>
<Input placeholder="10.0.0.11" />
</Form.Item>
<Form.Item label={t('backends.server.port')} name="port" rules={[{ required: true }]}>
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('backends.server.weight')} name="weight" extra={t('backends.server.weightHint')}>
<InputNumber min={0} max={256} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label={t('backends.server.backup')} name="backup" valuePropName="checked"
extra={t('backends.server.backupHint')}>
<Switch />
</Form.Item>
<Form.Item label={t('backends.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
)