diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index ba73e79..eb13d48 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -24,6 +24,8 @@ import ( "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/domains" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" "git.netcell-it.de/projekte/edgeguard-native/internal/services/routingrules" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" @@ -118,12 +120,16 @@ func main() { domainsRepo := domains.New(pool) backendsRepo := backends.New(pool) routingRepo := routingrules.New(pool) + ifsRepo := networkifs.New(pool) + ipsRepo := ipaddresses.New(pool) authed := v1.Group("") authed.Use(requireAuth) handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID).Register(authed) handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID).Register(authed) handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID).Register(authed) + handlers.NewNetworksHandler(ifsRepo, ipsRepo, auditRepo, nodeID).Register(authed) + handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed) handlers.NewClusterHandler(clusterStore, nodeID).Register(authed) } diff --git a/deploy/systemd/edgeguard-api.service b/deploy/systemd/edgeguard-api.service index 3686907..fbf689c 100644 --- a/deploy/systemd/edgeguard-api.service +++ b/deploy/systemd/edgeguard-api.service @@ -23,7 +23,7 @@ ProtectKernelModules=true ProtectControlGroups=true PrivateTmp=true PrivateDevices=true -RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK SystemCallFilter=@system-service ReadWritePaths=/etc/edgeguard /var/lib/edgeguard /var/log/edgeguard diff --git a/internal/database/migrations/0009_networks.sql b/internal/database/migrations/0009_networks.sql new file mode 100644 index 0000000..357f4a6 --- /dev/null +++ b/internal/database/migrations/0009_networks.sql @@ -0,0 +1,72 @@ +-- +goose Up +-- +goose StatementBegin + +-- Network interfaces declared by the operator. The kernel-side +-- interfaces (eth0, eth1, ...) appear here via "discovery" — same +-- name as the OS device. VLAN/bond/bridge interfaces are operator- +-- created and the runtime renderer (Phase-3) will translate them +-- into /etc/systemd/network/*.network files. +-- +-- role drives where the firewall/HAProxy generators expect this +-- interface to live: 'wan' = public-facing, 'lan' = internal, +-- 'dmz' = quarantined, 'mgmt' = admin-only, 'cluster' = peer-mTLS. +CREATE TABLE IF NOT EXISTS network_interfaces ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'ethernet', + parent TEXT, + vlan_id INTEGER, + role TEXT NOT NULL DEFAULT 'lan', + mtu INTEGER, + active BOOLEAN NOT NULL DEFAULT TRUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT network_interfaces_name_unique UNIQUE (name), + CONSTRAINT network_interfaces_type_check CHECK (type IN ('ethernet', 'vlan', 'bond', 'bridge', 'wireguard')), + CONSTRAINT network_interfaces_role_check CHECK (role IN ('wan', 'lan', 'dmz', 'mgmt', 'cluster')), + CONSTRAINT network_interfaces_vlan_check CHECK ( + (type = 'vlan' AND vlan_id BETWEEN 1 AND 4094 AND parent IS NOT NULL) + OR (type <> 'vlan') + ) +); + +CREATE INDEX IF NOT EXISTS idx_network_interfaces_role ON network_interfaces (role); +CREATE INDEX IF NOT EXISTS idx_network_interfaces_active ON network_interfaces (active) WHERE active; + +-- IP addresses bound to a declared interface. +-- +-- is_vip flags addresses that should follow the cluster's active +-- node (Phase-3 floating-IP / VRRP-via-hoster-API logic). vip_priority +-- gives the failover preference (higher wins); ignored for non-VIP +-- rows. +-- +-- The same address can live on the same interface only once +-- (UNIQUE), but the same address may legitimately appear on +-- different interfaces (e.g. as anycast endpoint). +CREATE TABLE IF NOT EXISTS ip_addresses ( + id BIGSERIAL PRIMARY KEY, + interface_id BIGINT NOT NULL REFERENCES network_interfaces(id) ON DELETE CASCADE, + address TEXT NOT NULL, + prefix INTEGER NOT NULL, + is_vip BOOLEAN NOT NULL DEFAULT FALSE, + vip_priority INTEGER, + description TEXT, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ip_addresses_iface_addr_unique UNIQUE (interface_id, address), + CONSTRAINT ip_addresses_prefix_check CHECK (prefix BETWEEN 0 AND 128) +); + +CREATE INDEX IF NOT EXISTS idx_ip_addresses_iface ON ip_addresses (interface_id); +CREATE INDEX IF NOT EXISTS idx_ip_addresses_vip ON ip_addresses (is_vip) WHERE is_vip; +CREATE INDEX IF NOT EXISTS idx_ip_addresses_active ON ip_addresses (active) WHERE active; + +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS ip_addresses; +DROP TABLE IF EXISTS network_interfaces; +-- +goose StatementEnd diff --git a/internal/handlers/ipaddresses.go b/internal/handlers/ipaddresses.go new file mode 100644 index 0000000..1b08561 --- /dev/null +++ b/internal/handlers/ipaddresses.go @@ -0,0 +1,116 @@ +package handlers + +import ( + "errors" + "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/ipaddresses" +) + +type IPAddressesHandler struct { + Repo *ipaddresses.Repo + Audit *audit.Repo + NodeID string +} + +func NewIPAddressesHandler(repo *ipaddresses.Repo, a *audit.Repo, nodeID string) *IPAddressesHandler { + return &IPAddressesHandler{Repo: repo, Audit: a, NodeID: nodeID} +} + +func (h *IPAddressesHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/ip-addresses") + g.GET("", h.List) + g.POST("", h.Create) + g.GET("/:id", h.Get) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) +} + +func (h *IPAddressesHandler) List(c *gin.Context) { + out, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"ip_addresses": out}) +} + +func (h *IPAddressesHandler) Get(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + x, err := h.Repo.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, ipaddresses.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, x) +} + +func (h *IPAddressesHandler) Create(c *gin.Context) { + var req models.IPAddress + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + 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), "ip_address.create", + req.Address, out, h.NodeID) + response.Created(c, out) +} + +func (h *IPAddressesHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req models.IPAddress + 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, ipaddresses.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.update", + out.Address, out, h.NodeID) + response.OK(c, out) +} + +func (h *IPAddressesHandler) 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, ipaddresses.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "ip_address.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) +} diff --git a/internal/handlers/networks.go b/internal/handlers/networks.go new file mode 100644 index 0000000..136c0e8 --- /dev/null +++ b/internal/handlers/networks.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "errors" + "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/ipaddresses" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs" +) + +type NetworksHandler struct { + Repo *networkifs.Repo + IPs *ipaddresses.Repo + Audit *audit.Repo + NodeID string +} + +func NewNetworksHandler(repo *networkifs.Repo, ips *ipaddresses.Repo, a *audit.Repo, nodeID string) *NetworksHandler { + return &NetworksHandler{Repo: repo, IPs: ips, Audit: a, NodeID: nodeID} +} + +func (h *NetworksHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/network-interfaces") + g.GET("", h.List) + g.POST("", h.Create) + g.GET("/:id", h.Get) + g.PUT("/:id", h.Update) + g.DELETE("/:id", h.Delete) + g.GET("/:id/ip-addresses", h.ListIPs) +} + +func (h *NetworksHandler) List(c *gin.Context) { + out, err := h.Repo.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"interfaces": out}) +} + +func (h *NetworksHandler) Get(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + x, err := h.Repo.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, networkifs.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, x) +} + +func (h *NetworksHandler) Create(c *gin.Context) { + var req models.NetworkInterface + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + 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), "network_interface.create", req.Name, out, h.NodeID) + response.Created(c, out) +} + +func (h *NetworksHandler) Update(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req models.NetworkInterface + 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, networkifs.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.update", out.Name, out, h.NodeID) + response.OK(c, out) +} + +func (h *NetworksHandler) 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, networkifs.ErrNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "network_interface.delete", + strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) +} + +// 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) { + id, ok := parseID(c) + if !ok { + return + } + out, err := h.IPs.ListForInterface(c.Request.Context(), id) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"ip_addresses": out}) +} diff --git a/internal/handlers/system.go b/internal/handlers/system.go index 56b88a0..e017c09 100644 --- a/internal/handlers/system.go +++ b/internal/handlers/system.go @@ -2,6 +2,7 @@ package handlers import ( "log/slog" + "net" "net/http" "os" "os/exec" @@ -30,6 +31,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) { g.GET("/health", h.Health) g.GET("/package-versions", h.PackageVersions) g.POST("/upgrade", h.Upgrade) + g.GET("/interfaces", h.Interfaces) } func (h *SystemHandler) Health(c *gin.Context) { @@ -116,6 +118,108 @@ rm -f /tmp/edgeguard-upgrade.sh }) } +// addrInfo + interfaceInfo mirror the relevant subset of `ip -j addr +// show` so the frontend keeps its existing parsing code. +type addrInfo struct { + Family string `json:"family"` // "inet" | "inet6" + Local string `json:"local"` + PrefixLen int `json:"prefixlen"` +} + +type interfaceInfo struct { + IfIndex int `json:"ifindex"` + IfName string `json:"ifname"` + Flags []string `json:"flags"` + MTU int `json:"mtu"` + LinkType string `json:"link_type,omitempty"` + Address string `json:"address,omitempty"` + AddrInfo []addrInfo `json:"addr_info"` +} + +// Interfaces enumerates the kernel-side network interfaces using +// Go's net.Interfaces() — no shell-out, no AF_NETLINK exception +// in the systemd hardening required (the original `ip -j addr` +// approach was blocked by RestrictAddressFamilies). +// +// Output shape mirrors `ip -j addr show` enough for the UI's +// Networks "system-discovered" card. +func (h *SystemHandler) Interfaces(c *gin.Context) { + ifaces, err := net.Interfaces() + if err != nil { + slog.Warn("system/interfaces: net.Interfaces failed", "error", err) + response.OK(c, gin.H{"interfaces": []interfaceInfo{}}) + return + } + out := make([]interfaceInfo, 0, len(ifaces)) + for _, ifc := range ifaces { + info := interfaceInfo{ + IfIndex: ifc.Index, + IfName: ifc.Name, + MTU: ifc.MTU, + Address: ifc.HardwareAddr.String(), + LinkType: classifyLinkType(ifc), + Flags: flagsToList(ifc.Flags), + AddrInfo: []addrInfo{}, + } + addrs, err := ifc.Addrs() + if err != nil { + out = append(out, info) + continue + } + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok { + continue + } + family := "inet" + if ipnet.IP.To4() == nil { + family = "inet6" + } + ones, _ := ipnet.Mask.Size() + info.AddrInfo = append(info.AddrInfo, addrInfo{ + Family: family, + Local: ipnet.IP.String(), + PrefixLen: ones, + }) + } + out = append(out, info) + } + response.OK(c, gin.H{"interfaces": out}) +} + +func classifyLinkType(ifc net.Interface) string { + if ifc.Flags&net.FlagLoopback != 0 { + return "loopback" + } + if len(ifc.HardwareAddr) > 0 { + return "ether" + } + return "" +} + +func flagsToList(f net.Flags) []string { + var out []string + if f&net.FlagUp != 0 { + out = append(out, "UP") + } + if f&net.FlagBroadcast != 0 { + out = append(out, "BROADCAST") + } + if f&net.FlagLoopback != 0 { + out = append(out, "LOOPBACK") + } + if f&net.FlagPointToPoint != 0 { + out = append(out, "POINTOPOINT") + } + if f&net.FlagMulticast != 0 { + out = append(out, "MULTICAST") + } + if f&net.FlagRunning != 0 { + out = append(out, "LOWER_UP") + } + return out +} + // parseAptPolicy extracts "Installed: x" and "Candidate: y" from // apt-cache policy output. Both can be "(none)"; we normalise that to // empty string. diff --git a/internal/models/ip_address.go b/internal/models/ip_address.go new file mode 100644 index 0000000..53b067b --- /dev/null +++ b/internal/models/ip_address.go @@ -0,0 +1,18 @@ +package models + +import "time" + +type IPAddress struct { + ID int64 `gorm:"primaryKey" json:"id"` + InterfaceID int64 `gorm:"column:interface_id" json:"interface_id"` + Address string `gorm:"column:address" json:"address"` + Prefix int `gorm:"column:prefix" json:"prefix"` + IsVIP bool `gorm:"column:is_vip" json:"is_vip"` + VIPPriority *int `gorm:"column:vip_priority" json:"vip_priority,omitempty"` + Description *string `gorm:"column:description" json:"description,omitempty"` + 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 (IPAddress) TableName() string { return "ip_addresses" } diff --git a/internal/models/network_interface.go b/internal/models/network_interface.go new file mode 100644 index 0000000..7be529c --- /dev/null +++ b/internal/models/network_interface.go @@ -0,0 +1,19 @@ +package models + +import "time" + +type NetworkInterface struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name;uniqueIndex" json:"name"` + 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"` + Role string `gorm:"column:role" json:"role"` + MTU *int `gorm:"column:mtu" json:"mtu,omitempty"` + Active bool `gorm:"column:active" json:"active"` + Description *string `gorm:"column:description" json:"description,omitempty"` + CreatedAt time.Time `gorm:"column:created_at" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"` +} + +func (NetworkInterface) TableName() string { return "network_interfaces" } diff --git a/internal/services/ipaddresses/ipaddresses.go b/internal/services/ipaddresses/ipaddresses.go new file mode 100644 index 0000000..88af925 --- /dev/null +++ b/internal/services/ipaddresses/ipaddresses.go @@ -0,0 +1,129 @@ +// Package ipaddresses implements CRUD against the `ip_addresses` +// table — addresses bound to operator-declared network interfaces, +// with is_vip + vip_priority for cluster-failover semantics. +package ipaddresses + +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("ip address not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, interface_id, address, prefix, is_vip, vip_priority, + description, active, created_at, updated_at +FROM ip_addresses +` + +func (r *Repo) List(ctx context.Context) ([]models.IPAddress, error) { + rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY interface_id ASC, address ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.IPAddress, 0, 8) + for rows.Next() { + a, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *a) + } + return out, rows.Err() +} + +func (r *Repo) ListForInterface(ctx context.Context, ifaceID int64) ([]models.IPAddress, error) { + rows, err := r.Pool.Query(ctx, baseSelect+ + " WHERE interface_id = $1 ORDER BY address ASC", ifaceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.IPAddress, 0, 4) + for rows.Next() { + a, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *a) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*models.IPAddress, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + a, err := scan(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return a, nil +} + +func (r *Repo) Create(ctx context.Context, a models.IPAddress) (*models.IPAddress, error) { + row := r.Pool.QueryRow(ctx, ` +INSERT INTO ip_addresses (interface_id, address, prefix, is_vip, vip_priority, description, active) +VALUES ($1, $2, $3, $4, $5, $6, $7) +RETURNING id, interface_id, address, prefix, is_vip, vip_priority, + description, active, created_at, updated_at`, + a.InterfaceID, a.Address, a.Prefix, a.IsVIP, a.VIPPriority, a.Description, a.Active) + return scan(row) +} + +func (r *Repo) Update(ctx context.Context, id int64, a models.IPAddress) (*models.IPAddress, error) { + row := r.Pool.QueryRow(ctx, ` +UPDATE ip_addresses SET + interface_id = $1, address = $2, prefix = $3, + is_vip = $4, vip_priority = $5, description = $6, active = $7, + updated_at = NOW() +WHERE id = $8 +RETURNING id, interface_id, address, prefix, is_vip, vip_priority, + description, active, created_at, updated_at`, + a.InterfaceID, a.Address, a.Prefix, a.IsVIP, a.VIPPriority, a.Description, a.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 ip_addresses 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.IPAddress, error) { + var a models.IPAddress + if err := row.Scan( + &a.ID, &a.InterfaceID, &a.Address, &a.Prefix, + &a.IsVIP, &a.VIPPriority, + &a.Description, &a.Active, + &a.CreatedAt, &a.UpdatedAt, + ); err != nil { + return nil, err + } + return &a, nil +} diff --git a/internal/services/networkifs/networkifs.go b/internal/services/networkifs/networkifs.go new file mode 100644 index 0000000..5ec7dd4 --- /dev/null +++ b/internal/services/networkifs/networkifs.go @@ -0,0 +1,110 @@ +// Package networkifs implements CRUD against the +// `network_interfaces` table — operator-declared interfaces +// (ethernet, vlan, bond, bridge, wireguard). +package networkifs + +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("network interface not found") + +type Repo struct { + Pool *pgxpool.Pool +} + +func New(pool *pgxpool.Pool) *Repo { return &Repo{Pool: pool} } + +const baseSelect = ` +SELECT id, name, type, parent, vlan_id, role, mtu, active, description, + created_at, updated_at +FROM network_interfaces +` + +func (r *Repo) List(ctx context.Context) ([]models.NetworkInterface, error) { + rows, err := r.Pool.Query(ctx, baseSelect+" ORDER BY name ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.NetworkInterface, 0, 8) + for rows.Next() { + i, err := scan(rows) + if err != nil { + return nil, err + } + out = append(out, *i) + } + return out, rows.Err() +} + +func (r *Repo) Get(ctx context.Context, id int64) (*models.NetworkInterface, error) { + row := r.Pool.QueryRow(ctx, baseSelect+" WHERE id = $1", id) + i, err := scan(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return i, nil +} + +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, + created_at, updated_at`, + i.Name, i.Type, i.Parent, i.VLANID, 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, + updated_at = NOW() +WHERE id = $9 +RETURNING id, name, type, parent, vlan_id, 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) + 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 network_interfaces 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.NetworkInterface, error) { + var i models.NetworkInterface + if err := row.Scan( + &i.ID, &i.Name, &i.Type, &i.Parent, &i.VLANID, + &i.Role, &i.MTU, &i.Active, &i.Description, + &i.CreatedAt, &i.UpdatedAt, + ); err != nil { + return nil, err + } + return &i, nil +} diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 9c38ca5..3f69944 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -16,21 +16,37 @@ const DashboardPage = lazy(() => import('./pages/Dashboard')) const DomainsPage = lazy(() => import('./pages/Domains')) const BackendsPage = lazy(() => import('./pages/Backends')) const RoutingRulesPage = lazy(() => import('./pages/RoutingRules')) +const NetworksPage = lazy(() => import('./pages/Networks')) +const IPAddressesPage = lazy(() => import('./pages/IPAddresses')) const ClusterPage = lazy(() => import('./pages/Cluster')) const SettingsPage = lazy(() => import('./pages/Settings')) const queryClient = new QueryClient({ defaultOptions: { - queries: { - retry: 1, - refetchOnWindowFocus: false, - }, + queries: { retry: 1, refetchOnWindowFocus: false }, }, }) -// RequireAuth wraps protected routes. If we have a cached user but -// the cookie is stale, the first API call will 401 and the global -// interceptor in api/client.ts boots the user back to /login. +// Theme tokens 1:1 wie mail-gateway/enconf — colorPrimary, font, +// borderRadius, controlHeight. enterprise.css ergänzt mit eigenen +// Layout-Klassen (.app-layout, .sidebar, .header, …). +const antdTheme = { + token: { + colorPrimary: '#0EA5E9', + borderRadius: 6, + borderRadiusLG: 8, + fontSize: 13, + fontWeightStrong: 600, + fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif", + colorBgContainer: '#FFFFFF', + colorBgLayout: '#F8FAFC', + colorBorder: '#E2E8F0', + colorText: '#0F172A', + colorTextSecondary: '#64748B', + controlHeight: 34, + }, +} + function RequireAuth({ children }: { children: ReactNode }) { const user = useAuthStore((s) => s.user) const location = useLocation() @@ -40,9 +56,6 @@ function RequireAuth({ children }: { children: ReactNode }) { return <>{children} } -// SetupGate fetches /setup/status on mount; if the server says -// !completed, hard-redirect to /setup. This catches the case of a -// fresh install where the user goes straight to /dashboard. function SetupGate({ children }: { children: ReactNode }) { const location = useLocation() useEffect(() => { @@ -65,11 +78,11 @@ export default function App() { const antdLocale = i18n.language?.startsWith('de') ? deDE : enUS return ( - + - }> + }> useAuthStore.getState().set(u)} />} /> useAuthStore.getState().set(u)} />} /> @@ -80,6 +93,8 @@ export default function App() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/AppLayout.tsx b/management-ui/src/components/Layout/AppLayout.tsx index 345f78d..da8a049 100644 --- a/management-ui/src/components/Layout/AppLayout.tsx +++ b/management-ui/src/components/Layout/AppLayout.tsx @@ -1,25 +1,52 @@ -import { Layout } from 'antd' -import { Outlet } from 'react-router-dom' +import { useState } from 'react' +import { Outlet, useLocation } from 'react-router-dom' +import { useTranslation } from 'react-i18next' -import Header from './Header' import Sidebar from './Sidebar' +import Header from './Header' import UpdateBanner from '../UpdateBanner' -const { Sider, Content } = Layout +// PAGE_TITLES maps the pathname to an i18n nav key. Header reads +// this to render "where you are". Empty fallback = app.title. +const PAGE_TITLES: Record = { + '/dashboard': 'nav.dashboard', + '/domains': 'nav.domains', + '/backends': 'nav.backends', + '/routing-rules': 'nav.routing', + '/networks': 'nav.networks', + '/ip-addresses': 'nav.ipAddresses', + '/cluster': 'nav.cluster', + '/settings': 'nav.settings', +} export default function AppLayout() { + const [sidebarOpen, setSidebarOpen] = useState(false) + const location = useLocation() + const { t } = useTranslation() + + const titleKey = Object.entries(PAGE_TITLES) + .find(([p]) => location.pathname === p || location.pathname.startsWith(p + '/'))?.[1] + const title = titleKey ? t(titleKey) : t('app.title') + return ( - - - - - -
+
+ {sidebarOpen && ( +
setSidebarOpen(false)} + aria-hidden="true" + /> + )} + + setSidebarOpen(false)} /> + +
+
setSidebarOpen(true)} /> - +
- - - +
+
+
) } diff --git a/management-ui/src/components/Layout/Header.tsx b/management-ui/src/components/Layout/Header.tsx index ccec820..43464a1 100644 --- a/management-ui/src/components/Layout/Header.tsx +++ b/management-ui/src/components/Layout/Header.tsx @@ -1,15 +1,18 @@ -import { LogoutOutlined, UserOutlined } from '@ant-design/icons' -import { Dropdown, Layout, Space, Typography } from 'antd' +import { Button, Dropdown, Select, Space } from 'antd' +import { GlobalOutlined, LogoutOutlined, MenuOutlined, UserOutlined } from '@ant-design/icons' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import apiClient from '../../api/client' import { useAuthStore } from '../../stores/auth' -const { Header: AntHeader } = Layout +interface HeaderProps { + pageTitle: string + onMenuToggle: () => void +} -export default function Header() { - const { t } = useTranslation() +export default function Header({ pageTitle, onMenuToggle }: HeaderProps) { + const { t, i18n } = useTranslation() const navigate = useNavigate() const user = useAuthStore((s) => s.user) const clear = useAuthStore((s) => s.clear) @@ -20,25 +23,55 @@ export default function Header() { navigate('/login', { replace: true }) } + const onLangChange = (value: string) => { + void i18n.changeLanguage(value) + } + return ( - - , - label: t('auth.logout'), - onClick: onLogout, - }, - ], - }} - > - - - {user?.actor ?? '—'} - - - +
+
+
+
+ ({ value: i.id, label: ifaceLabel(i.id) }))} + placeholder={t('ips.selectInterface')} + /> + + + + + + + + + + + p.is_vip !== c.is_vip}> + {({ getFieldValue }) => getFieldValue('is_vip') ? ( + + + + ) : null} + + + + + + + + + +
+ ) +} diff --git a/management-ui/src/pages/Networks/index.tsx b/management-ui/src/pages/Networks/index.tsx new file mode 100644 index 0000000..9be6d00 --- /dev/null +++ b/management-ui/src/pages/Networks/index.tsx @@ -0,0 +1,212 @@ +import { useState } from 'react' +import { Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, Tooltip, Typography, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' + +interface NetworkInterface { + id: number + name: string + type: 'ethernet' | 'vlan' | 'bond' | 'bridge' | 'wireguard' + parent?: string | null + vlan_id?: number | null + role: 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster' + mtu?: number | null + active: boolean + description?: string | null + created_at: string + updated_at: string +} + +interface IfaceFormValues { + name: string + type: NetworkInterface['type'] + parent?: string + vlan_id?: number + role: NetworkInterface['role'] + mtu?: number + active: boolean + description?: string +} + +interface SystemInterface { + ifname: string + link_type?: string + address?: string + flags?: string[] + addr_info?: Array<{ family: 'inet' | 'inet6'; local: string; prefixlen: number }> +} + +async function listInterfaces(): Promise { + const r = await apiClient.get('/network-interfaces') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { interfaces?: NetworkInterface[] }).interfaces ?? [] +} + +async function listSystemInterfaces(): Promise { + const r = await apiClient.get('/system/interfaces') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { interfaces?: SystemInterface[] }).interfaces ?? [] +} + +export default function NetworksPage() { + const { t } = useTranslation() + const qc = useQueryClient() + + const { data: ifs, isLoading } = useQuery({ queryKey: ['network-interfaces'], queryFn: listInterfaces }) + const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces, refetchInterval: 60_000 }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: IfaceFormValues) => { await apiClient.post('/network-interfaces', v) }, + onSuccess: () => { + message.success(t('common.save')) + setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) + }, + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: IfaceFormValues }) => { await apiClient.put(`/network-interfaces/${id}`, v) }, + onSuccess: () => { + message.success(t('common.save')) + setEditing(null); form.resetFields() + void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/network-interfaces/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) }, + }) + + const roleColor: Record = { + wan: 'blue', lan: 'green', dmz: 'orange', mgmt: 'purple', cluster: 'magenta', + } + + const columns: ColumnsType = [ + { title: t('networks.name'), dataIndex: 'name', key: 'name', render: (s: string) => {s} }, + { title: t('networks.type'), dataIndex: 'type', key: 'type' }, + { + title: t('networks.vlan'), key: 'vlan', + render: (_, row) => row.type === 'vlan' ? {row.parent}.{row.vlan_id} : '—', + }, + { + title: t('networks.role'), dataIndex: 'role', key: 'role', + render: (r: NetworkInterface['role']) => {r.toUpperCase()}, + }, + { title: t('networks.mtu'), dataIndex: 'mtu', key: 'mtu', render: (v?: number) => v ?? '—' }, + { title: t('networks.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') }, + { + title: t('networks.actions'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)} + > + + + + ), + }, + ] + + return ( +
+ {t('networks.title')} + {t('networks.intro')} + + + + {(sys ?? []).map((i) => { + const v4 = (i.addr_info ?? []).filter((a) => a.family === 'inet').map((a) => `${a.local}/${a.prefixlen}`) + const v6 = (i.addr_info ?? []).filter((a) => a.family === 'inet6').map((a) => `${a.local}/${a.prefixlen}`) + return ( + + {i.ifname}{v4[0] ? ` · ${v4[0]}` : ''} + + ) + })} + {(sys ?? []).length === 0 && } + + + + + + + + { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + width={560} + > +
{ + if (editing) update.mutate({ id: editing.id, v }) + else create.mutate(v) + }} + > + + + + + + + + + + + ) : null} + + +