feat: Networks-Members für bridge/bond + System-Rules-Card + Theme-Revert
* Migration 0011: members JSONB für network_interfaces. Bridge/bond brauchen ≥1 Member (NOT VALID-Constraint, schont bestehende Rows). vlan/wireguard/ethernet ignorieren das Feld. * Backend-Validation pro Typ: vlan→parent+vlan_id, bridge/bond→members, ethernet/wireguard→keins. Repo serialisiert via JSONB. * Form Networks: Members-Multi-Select für bridge/bond, Composition- Spalte zeigt vlan-tag bzw. Member-Liste. * Firewall-Rules-Tab zeigt jetzt SystemRulesCard ganz oben — Anti- Lockout (SSH/443), stateful baseline, default-deny-Erklärung. * Theme-Tokens 1:1 mail-gateway: fontSize 13, controlHeight 34 (vorher zu dichtes 12/28). Density kommt vom DataTable size="small". * Makefile publish-amd64 lädt jetzt auch edgeguard-ui_*_all.deb und edgeguard_*_all.deb hoch (vorher nur api). * Version 1.0.0 → 1.0.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
31
internal/database/migrations/0011_network_members.sql
Normal file
31
internal/database/migrations/0011_network_members.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- +goose Up
|
||||
-- +goose StatementBegin
|
||||
|
||||
-- Multi-member support for bridge/bond. `parent` (single TEXT) was
|
||||
-- enough for VLANs but bridges and bonds aggregate N kernel ifaces.
|
||||
-- We store the member-list as a JSONB array of iface-names so the
|
||||
-- runtime renderer can emit the correct systemd-networkd .network
|
||||
-- files (Bridge=br0 on each member etc.).
|
||||
ALTER TABLE network_interfaces
|
||||
ADD COLUMN IF NOT EXISTS members JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
-- Bridge and bond MUST have ≥ 1 member; vlan/wireguard/ethernet
|
||||
-- ignore the field. NOT VALID skips the back-fill check on the
|
||||
-- existing rows — they may have been declared in an earlier release
|
||||
-- before the field existed and we don't want to break the upgrade.
|
||||
-- Inserts and UPDATEs from now on are validated normally.
|
||||
ALTER TABLE network_interfaces
|
||||
DROP CONSTRAINT IF EXISTS network_interfaces_members_check;
|
||||
ALTER TABLE network_interfaces
|
||||
ADD CONSTRAINT network_interfaces_members_check CHECK (
|
||||
type NOT IN ('bridge', 'bond')
|
||||
OR jsonb_array_length(members) >= 1
|
||||
) NOT VALID;
|
||||
|
||||
-- +goose StatementEnd
|
||||
|
||||
-- +goose Down
|
||||
-- +goose StatementBegin
|
||||
ALTER TABLE network_interfaces DROP CONSTRAINT IF EXISTS network_interfaces_members_check;
|
||||
ALTER TABLE network_interfaces DROP COLUMN IF EXISTS members;
|
||||
-- +goose StatementEnd
|
||||
@@ -66,6 +66,10 @@ func (h *NetworksHandler) Create(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateInterface(&req); err != nil {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
out, err := h.Repo.Create(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
response.Internal(c, err)
|
||||
@@ -85,6 +89,10 @@ func (h *NetworksHandler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, err)
|
||||
return
|
||||
}
|
||||
if err := validateInterface(&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, networkifs.ErrNotFound) {
|
||||
@@ -116,6 +124,40 @@ func (h *NetworksHandler) Delete(c *gin.Context) {
|
||||
response.NoContent(c)
|
||||
}
|
||||
|
||||
// validateInterface enforces the per-type rules that the SQL CHECK
|
||||
// constraints alone can't express in a friendly way:
|
||||
// - vlan: parent + vlan_id required
|
||||
// - bridge / bond: ≥ 1 member required, vlan_id forbidden
|
||||
// - ethernet / wireguard: parent + members + vlan_id ignored
|
||||
//
|
||||
// The caller normalises empty members to nil before calling so the
|
||||
// repo always receives [] (NOT NULL).
|
||||
func validateInterface(i *models.NetworkInterface) error {
|
||||
switch i.Type {
|
||||
case "vlan":
|
||||
if i.Parent == nil || *i.Parent == "" {
|
||||
return errors.New("vlan requires parent")
|
||||
}
|
||||
if i.VLANID == nil {
|
||||
return errors.New("vlan requires vlan_id")
|
||||
}
|
||||
i.Members = nil
|
||||
case "bridge", "bond":
|
||||
if len(i.Members) == 0 {
|
||||
return errors.New(i.Type + " requires at least one member interface")
|
||||
}
|
||||
i.Parent = nil
|
||||
i.VLANID = nil
|
||||
case "ethernet", "wireguard":
|
||||
i.Parent = nil
|
||||
i.VLANID = nil
|
||||
i.Members = nil
|
||||
default:
|
||||
return errors.New("unknown interface type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListIPs surfaces the addresses bound to a single interface — UI
|
||||
// uses this for the per-interface IP-list tab.
|
||||
func (h *NetworksHandler) ListIPs(c *gin.Context) {
|
||||
|
||||
@@ -8,6 +8,7 @@ type NetworkInterface struct {
|
||||
Type string `gorm:"column:type" json:"type"`
|
||||
Parent *string `gorm:"column:parent" json:"parent,omitempty"`
|
||||
VLANID *int `gorm:"column:vlan_id" json:"vlan_id,omitempty"`
|
||||
Members []string `gorm:"column:members;type:jsonb" json:"members"`
|
||||
Role string `gorm:"column:role" json:"role"`
|
||||
MTU *int `gorm:"column:mtu" json:"mtu,omitempty"`
|
||||
Active bool `gorm:"column:active" json:"active"`
|
||||
|
||||
@@ -5,6 +5,7 @@ package networkifs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -22,7 +23,7 @@ type Repo struct {
|
||||
func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} }
|
||||
|
||||
const baseSelect = `
|
||||
SELECT id, name, type, parent, vlan_id, role, mtu, active, description,
|
||||
SELECT id, name, type, parent, vlan_id, members, role, mtu, active, description,
|
||||
created_at, updated_at
|
||||
FROM network_interfaces
|
||||
`
|
||||
@@ -58,24 +59,24 @@ func (r *Repo) Get(ctx context.Context, id int64) (*models.NetworkInterface, err
|
||||
|
||||
func (r *Repo) Create(ctx context.Context, i models.NetworkInterface) (*models.NetworkInterface, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO network_interfaces (name, type, parent, vlan_id, role, mtu, active, description)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, name, type, parent, vlan_id, role, mtu, active, description,
|
||||
INSERT INTO network_interfaces (name, type, parent, vlan_id, members, role, mtu, active, description)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7, $8, $9)
|
||||
RETURNING id, name, type, parent, vlan_id, members, role, mtu, active, description,
|
||||
created_at, updated_at`,
|
||||
i.Name, i.Type, i.Parent, i.VLANID, i.Role, i.MTU, i.Active, i.Description)
|
||||
i.Name, i.Type, i.Parent, i.VLANID, membersJSON(i.Members), i.Role, i.MTU, i.Active, i.Description)
|
||||
return scan(row)
|
||||
}
|
||||
|
||||
func (r *Repo) Update(ctx context.Context, id int64, i models.NetworkInterface) (*models.NetworkInterface, error) {
|
||||
row := r.Pool.QueryRow(ctx, `
|
||||
UPDATE network_interfaces SET
|
||||
name = $1, type = $2, parent = $3, vlan_id = $4,
|
||||
role = $5, mtu = $6, active = $7, description = $8,
|
||||
name = $1, type = $2, parent = $3, vlan_id = $4, members = $5::jsonb,
|
||||
role = $6, mtu = $7, active = $8, description = $9,
|
||||
updated_at = NOW()
|
||||
WHERE id = $9
|
||||
RETURNING id, name, type, parent, vlan_id, role, mtu, active, description,
|
||||
WHERE id = $10
|
||||
RETURNING id, name, type, parent, vlan_id, members, role, mtu, active, description,
|
||||
created_at, updated_at`,
|
||||
i.Name, i.Type, i.Parent, i.VLANID, i.Role, i.MTU, i.Active, i.Description, id)
|
||||
i.Name, i.Type, i.Parent, i.VLANID, membersJSON(i.Members), i.Role, i.MTU, i.Active, i.Description, id)
|
||||
out, err := scan(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -97,14 +98,36 @@ func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// membersJSON normalises a Go slice into a JSON array literal that
|
||||
// Postgres can cast to JSONB. nil → "[]" so the NOT NULL constraint
|
||||
// always holds.
|
||||
func membersJSON(m []string) string {
|
||||
if m == nil {
|
||||
return "[]"
|
||||
}
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func scan(row interface{ Scan(...any) error }) (*models.NetworkInterface, error) {
|
||||
var i models.NetworkInterface
|
||||
var (
|
||||
i models.NetworkInterface
|
||||
raw []byte
|
||||
)
|
||||
if err := row.Scan(
|
||||
&i.ID, &i.Name, &i.Type, &i.Parent, &i.VLANID,
|
||||
&i.ID, &i.Name, &i.Type, &i.Parent, &i.VLANID, &raw,
|
||||
&i.Role, &i.MTU, &i.Active, &i.Description,
|
||||
&i.CreatedAt, &i.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &i.Members); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if i.Members == nil {
|
||||
i.Members = []string{}
|
||||
}
|
||||
return &i, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user