diff --git a/Makefile b/Makefile index 71b0b73..f28264a 100644 --- a/Makefile +++ b/Makefile @@ -87,10 +87,11 @@ GITEA_DEB_URL := https://git.netcell-it.de/api/packages/projekte/debian/pool/tri publish-amd64: deb-amd64 @TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \ if [ -z "$$TOK" ]; then echo "publish: ~/.gitea-token is empty"; exit 1; fi; \ - echo " -> publish edgeguard-api_$(VERSION)_amd64.deb"; \ - curl -sS -H "Authorization: token $$TOK" \ - --upload-file build/deb/edgeguard-api_$(VERSION)_amd64.deb \ - $(GITEA_DEB_URL) + for f in edgeguard-api_$(VERSION)_amd64.deb edgeguard-ui_$(VERSION)_all.deb edgeguard_$(VERSION)_all.deb; do \ + echo " -> publish $$f"; \ + curl -sS -H "Authorization: token $$TOK" --upload-file build/deb/$$f $(GITEA_DEB_URL); \ + echo ""; \ + done publish-arm64: deb-arm64 @TOK="$$(cat $$HOME/.gitea-token | tr -d '\n')"; \ diff --git a/VERSION b/VERSION index 3eefcb9..21e8796 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.0 +1.0.3 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 86ef771..f06a52a 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -35,7 +35,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.0.0" +var version = "1.0.3" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 21b73ce..13445b0 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.0" +var version = "1.0.3" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 126e79d..31d949f 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -5,7 +5,7 @@ import ( "time" ) -var version = "1.0.0" +var version = "1.0.3" func main() { log.Printf("edgeguard-scheduler %s starting", version) diff --git a/internal/database/migrations/0011_network_members.sql b/internal/database/migrations/0011_network_members.sql new file mode 100644 index 0000000..eb565e2 --- /dev/null +++ b/internal/database/migrations/0011_network_members.sql @@ -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 diff --git a/internal/handlers/networks.go b/internal/handlers/networks.go index 136c0e8..013d2a1 100644 --- a/internal/handlers/networks.go +++ b/internal/handlers/networks.go @@ -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) { diff --git a/internal/models/network_interface.go b/internal/models/network_interface.go index 7be529c..6951a2d 100644 --- a/internal/models/network_interface.go +++ b/internal/models/network_interface.go @@ -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"` diff --git a/internal/services/networkifs/networkifs.go b/internal/services/networkifs/networkifs.go index 5ec7dd4..b8476f7 100644 --- a/internal/services/networkifs/networkifs.go +++ b/internal/services/networkifs/networkifs.go @@ -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 } diff --git a/management-ui/package.json b/management-ui/package.json index 9e0a650..92acca3 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.0", + "version": "1.0.3", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 300ee80..4d973f2 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -29,9 +29,9 @@ const queryClient = new QueryClient({ }, }) -// Theme tokens 1:1 wie mail-gateway/enconf — colorPrimary, font, -// borderRadius, controlHeight. enterprise.css ergänzt mit eigenen -// Layout-Klassen (.app-layout, .sidebar, .header, …). +// Theme tokens 1:1 wie mail-gateway/enconf — Body-Density bekommen +// wir über AntD `size="small"` auf den Tables (DataTable hat das per +// default), nicht über kleinere Theme-Tokens. const antdTheme = { token: { colorPrimary: '#0EA5E9', diff --git a/management-ui/src/components/DataTable.tsx b/management-ui/src/components/DataTable.tsx index 9a5a3e4..1192166 100644 --- a/management-ui/src/components/DataTable.tsx +++ b/management-ui/src/components/DataTable.tsx @@ -109,6 +109,7 @@ export default function DataTable( )} + size="small" {...rest} dataSource={filtered} columns={enhancedCols} diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index c384b7e..ed9fb78 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -68,7 +68,7 @@ const NAV: NavSection[] = [ }, ] -const VERSION = '1.0.0' +const VERSION = '1.0.3' 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 72ccfe0..3031b7f 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -80,6 +80,16 @@ "comment": "Kommentar", "add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten", "deleteConfirm": "Diese NAT-Regel wirklich löschen?" + }, + "sys": { + "title": "System-Regeln (immer aktiv)", + "chain": "Chain", "match": "Match", "action": "Aktion", "note": "Hinweis", + "policy": "Default-Policy", + "policyValue": "Eingang DROP — alles muss explizit erlaubt werden.", + "order": "Auswertung", + "orderValue": "System-Regeln zuerst, danach Operator-Regeln top-down (priority asc, first-match).", + "lockout": "Anti-Lockout", + "lockoutValue": "SSH (22) und Management-UI (443) sind immer erreichbar — können auch vom Operator nicht versehentlich gesperrt werden." } }, "networks": { @@ -91,8 +101,15 @@ "name": "Name", "type": "Typ", "parent": "Parent-Interface", + "selectParent": "Parent wählen", "vlan": "VLAN", "vlanId": "VLAN-ID", + "composition": "Zusammensetzung", + "members": "Member-Interfaces", + "selectMembers": "Physische Interfaces wählen", + "membersRequired": "Mindestens ein Member-Interface erforderlich", + "membersHintBridge": "Eine Bridge bündelt mehrere physische Ports auf L2 — typisch zwei Ports für einen Software-Switch.", + "membersHintBond": "Ein Bond aggregiert mehrere physische Ports zu einem logischen Link (LACP / active-backup).", "role": "Rolle", "mtu": "MTU", "active": "Aktiv", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index a862453..13c9a58 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -80,6 +80,16 @@ "comment": "Comment", "add": "Add NAT rule", "edit": "Edit NAT rule", "deleteConfirm": "Really delete this NAT rule?" + }, + "sys": { + "title": "System rules (always active)", + "chain": "Chain", "match": "Match", "action": "Action", "note": "Note", + "policy": "Default policy", + "policyValue": "Input DROP — everything must be explicitly allowed.", + "order": "Evaluation", + "orderValue": "System rules first, then operator rules top-down (priority asc, first-match).", + "lockout": "Anti-lockout", + "lockoutValue": "SSH (22) and the management UI (443) are always reachable — even the operator can't accidentally lock themselves out." } }, "networks": { @@ -94,6 +104,12 @@ "selectParent": "Select parent", "vlan": "VLAN", "vlanId": "VLAN ID", + "composition": "Composition", + "members": "Member interfaces", + "selectMembers": "Select physical interfaces", + "membersRequired": "At least one member interface is required", + "membersHintBridge": "A bridge joins multiple physical ports at L2 — typically two ports for a software switch.", + "membersHintBond": "A bond aggregates multiple physical ports into one logical link (LACP / active-backup).", "role": "Role", "mtu": "MTU", "active": "Active", diff --git a/management-ui/src/pages/Firewall/Rules.tsx b/management-ui/src/pages/Firewall/Rules.tsx index 44f4ada..035ccdf 100644 --- a/management-ui/src/pages/Firewall/Rules.tsx +++ b/management-ui/src/pages/Firewall/Rules.tsx @@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useTranslation } from 'react-i18next' import DataTable from '../../components/DataTable' +import SystemRulesCard from './SystemRules' import apiClient, { isEnvelope } from '../../api/client' import type { AddressGroup, AddressObject, FwRule, FwService, ServiceGroup, Zone } from './types' @@ -186,6 +187,7 @@ export default function RulesTab() { return ( <> +