diff --git a/VERSION b/VERSION index b0f3d96..59e9e60 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.8 +1.0.11 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index df5d8c7..b26844e 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -22,6 +22,7 @@ import ( firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers" + wgrender "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard" "git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response" "git.netcell-it.de/projekte/edgeguard-native/internal/services/acme" "git.netcell-it.de/projekte/edgeguard-native/internal/services/audit" @@ -31,12 +32,14 @@ import ( "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/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/services/session" "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" + wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -var version = "1.0.8" +var version = "1.0.11" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") @@ -135,6 +138,9 @@ func main() { fwSvcGrp := firewall.NewServiceGroupsRepo(pool) fwRules := firewall.NewRulesRepo(pool) fwNAT := firewall.NewNATRulesRepo(pool) + secretsBox := secrets.New("") + wgIfaces := wgsvc.NewInterfacesRepo(pool) + wgPeers := wgsvc.NewPeersRepo(pool) // ACME (Let's Encrypt). Email comes from setup.json — the // wizard collects acme_email and the issuer registers an @@ -168,6 +174,14 @@ func main() { return firewallrender.New(pool).Render(ctx) } handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed) + + // WireGuard reload: re-render /etc/edgeguard/wireguard/*.conf + // + restart wg-quick@. Same pattern as the haproxy + + // firewall reloaders. + wgReloader := func(ctx context.Context) error { + return wgrender.New(pool, secretsBox).Render(ctx) + } + handlers.NewWireguardHandler(wgIfaces, wgPeers, secretsBox, auditRepo, nodeID, wgReloader).Register(authed) } mountUI(r) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 253b351..074caec 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.8" +var version = "1.0.11" const usage = `edgeguard-ctl — EdgeGuard CLI @@ -24,6 +24,7 @@ Commands: migrate dump [dir] Write embedded SQL files to dir (default: ./migrations) initdb Create PostgreSQL role + database (idempotent) render-config Regenerate haproxy / nftables configs from PG (--no-reload, --only=) + wg-import [--path ] Import existing /etc/wireguard/*.conf files into the DB cluster-join Join an existing cluster (Phase 3, not yet implemented) promote Promote this node's PG to primary (Phase 3, not yet implemented) dump-config Print effective config (Phase 3, not yet implemented) @@ -45,6 +46,8 @@ func main() { os.Exit(cmdInitDB(os.Args[2:])) case "render-config": os.Exit(cmdRenderConfig(os.Args[2:])) + case "wg-import": + os.Exit(cmdWGImport(os.Args[2:])) case "cluster-join", "cluster-leave", "promote", "dump-config": fmt.Fprintf(os.Stderr, "edgeguard-ctl: %q is a Phase-3 stub — not yet implemented\n", os.Args[1]) os.Exit(1) diff --git a/cmd/edgeguard-ctl/render.go b/cmd/edgeguard-ctl/render.go index 2f73cd4..6e0556a 100644 --- a/cmd/edgeguard-ctl/render.go +++ b/cmd/edgeguard-ctl/render.go @@ -12,6 +12,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "git.netcell-it.de/projekte/edgeguard-native/internal/haproxy" "git.netcell-it.de/projekte/edgeguard-native/internal/services/configorch" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" "git.netcell-it.de/projekte/edgeguard-native/internal/squid" "git.netcell-it.de/projekte/edgeguard-native/internal/unbound" "git.netcell-it.de/projekte/edgeguard-native/internal/wireguard" @@ -54,7 +55,7 @@ func cmdRenderConfig(args []string) int { hap := haproxy.New(pool) fw := firewall.New(pool) sq := squid.New() - wg := wireguard.New() + wg := wireguard.New(pool, secrets.New("")) ub := unbound.New() if skipReload { hap.SkipReload = true diff --git a/cmd/edgeguard-ctl/wg_import.go b/cmd/edgeguard-ctl/wg_import.go new file mode 100644 index 0000000..e9ea8df --- /dev/null +++ b/cmd/edgeguard-ctl/wg_import.go @@ -0,0 +1,62 @@ +package main + +import ( + "context" + "flag" + "fmt" + "os" + "time" + + "git.netcell-it.de/projekte/edgeguard-native/internal/database" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" +) + +// cmdWGImport reads /etc/wireguard/*.conf (or a custom dir via +// --path) and translates each [Interface]+[Peer] block into rows in +// wireguard_interfaces / wireguard_peers. Idempotent: ifaces with a +// name that already exists in the DB are skipped (no overwrite). +// +// Use after a fresh EdgeGuard install on a box that already had a +// hand-rolled WireGuard setup — keeps existing tunnels live across +// the migration. After import, run `edgeguard-ctl render-config` to +// re-emit the conf files under /etc/edgeguard/wireguard/ and start +// the wg-quick@ units. The original /etc/wireguard files are left +// in place for fallback. +func cmdWGImport(args []string) int { + fs := flag.NewFlagSet("wg-import", flag.ExitOnError) + path := fs.String("path", "/etc/wireguard", "directory holding *.conf files") + if err := fs.Parse(args); err != nil { + return 2 + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + pool, err := database.Open(ctx, database.ConnStringFromEnv()) + if err != nil { + fmt.Fprintln(os.Stderr, "wg-import: open db:", err) + return 1 + } + defer pool.Close() + + box := secrets.New("") + im := wireguard.NewImporter( + wireguard.NewInterfacesRepo(pool), + wireguard.NewPeersRepo(pool), + box, + ) + res, err := im.ImportDir(ctx, *path) + if err != nil { + fmt.Fprintln(os.Stderr, "wg-import:", err) + return 1 + } + fmt.Printf("wg-import: %d ifaces added, %d peers added\n", res.IfacesAdded, res.PeersAdded) + for _, s := range res.Skipped { + fmt.Printf(" skipped: %s\n", s) + } + if res.IfacesAdded > 0 { + fmt.Println("\nNext: edgeguard-ctl render-config (re-emits configs + starts wg-quick@)") + } + return 0 +} diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 7ca758f..c776c8e 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -5,7 +5,7 @@ import ( "time" ) -var version = "1.0.8" +var version = "1.0.11" func main() { log.Printf("edgeguard-scheduler %s starting", version) diff --git a/go.mod b/go.mod index 4ba536d..e9a2faa 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/go-acme/lego/v4 v4.35.2 github.com/jackc/pgx/v5 v5.9.2 github.com/pressly/goose/v3 v3.27.1 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.51.0 ) require ( @@ -37,6 +37,7 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -44,8 +45,8 @@ require ( golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 9e1a9e0..2171c19 100644 --- a/go.sum +++ b/go.sum @@ -88,6 +88,8 @@ github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZV github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -112,6 +114,8 @@ golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= @@ -121,8 +125,12 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/internal/database/migrations/0013_wireguard.sql b/internal/database/migrations/0013_wireguard.sql new file mode 100644 index 0000000..25ea117 --- /dev/null +++ b/internal/database/migrations/0013_wireguard.sql @@ -0,0 +1,79 @@ +-- +goose Up + +-- 0005 hat schon mal eine wireguard_peers-Tabelle angelegt (Option-A +-- Schema mit peer_type 'server|s2s|roadwarrior' ohne Interface-FK). +-- Wir bauen das Modell hier um: zwei Tabellen mit FK, getrennt nach +-- Modus (server-mode iface mit Peer-Roster vs. client-mode iface mit +-- inline upstream-peer). Da das Feature noch nicht produktiv genutzt +-- wurde (Tabelle leer beim ersten Rollout), droppen wir sie und +-- bauen frisch. + +-- +goose StatementBegin +DROP TABLE IF EXISTS wireguard_peers; +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS wireguard_interfaces ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + mode TEXT NOT NULL, + address_cidr TEXT NOT NULL, + listen_port INTEGER, + private_key_enc BYTEA NOT NULL, + public_key TEXT NOT NULL, + peer_endpoint TEXT, + peer_public_key TEXT, + peer_psk_enc BYTEA, + allowed_ips TEXT, + persistent_keepalive INTEGER, + mtu INTEGER, + role TEXT NOT NULL DEFAULT 'wan', + active BOOLEAN NOT NULL DEFAULT TRUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT wg_iface_mode_check CHECK (mode IN ('server', 'client')), + CONSTRAINT wg_iface_name_check CHECK (name ~ '^wg[a-z0-9-]{0,13}$'), + CONSTRAINT wg_iface_server_check CHECK ( + mode <> 'server' OR (listen_port BETWEEN 1 AND 65535) + ), + CONSTRAINT wg_iface_client_check CHECK ( + mode <> 'client' OR (peer_endpoint IS NOT NULL AND peer_public_key IS NOT NULL) + ) +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS wireguard_peers ( + id BIGSERIAL PRIMARY KEY, + interface_id BIGINT NOT NULL REFERENCES wireguard_interfaces(id) ON DELETE CASCADE, + name TEXT NOT NULL, + public_key TEXT NOT NULL, + private_key_enc BYTEA, + psk_enc BYTEA, + allowed_ips TEXT NOT NULL, + keepalive INTEGER, + last_handshake TIMESTAMPTZ, + transfer_rx BIGINT NOT NULL DEFAULT 0, + transfer_tx BIGINT NOT NULL DEFAULT 0, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT wg_peer_iface_pubkey_unique UNIQUE (interface_id, public_key) +); +-- +goose StatementEnd + +-- +goose StatementBegin +CREATE INDEX IF NOT EXISTS idx_wg_peers_iface ON wireguard_peers (interface_id); +-- +goose StatementEnd + +-- +goose Down + +-- +goose StatementBegin +DROP TABLE IF EXISTS wireguard_peers; +-- +goose StatementEnd + +-- +goose StatementBegin +DROP TABLE IF EXISTS wireguard_interfaces; +-- +goose StatementEnd diff --git a/internal/handlers/wireguard.go b/internal/handlers/wireguard.go new file mode 100644 index 0000000..a61b899 --- /dev/null +++ b/internal/handlers/wireguard.go @@ -0,0 +1,645 @@ +package handlers + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + qrcode "github.com/skip2/go-qrcode" + + "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/secrets" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" +) + +// WireguardHandler exposes /api/v1/wireguard/* — interfaces, peers, +// per-peer config download (wg-quick text + QR PNG) and a one-shot +// keypair generator. Mutations trigger Reloader so the kernel state +// stays in sync. +type WireguardHandler struct { + Ifaces *wireguard.InterfacesRepo + Peers *wireguard.PeersRepo + Box *secrets.Box + Audit *audit.Repo + NodeID string + Reloader func(ctx context.Context) error +} + +func NewWireguardHandler( + ifaces *wireguard.InterfacesRepo, + peers *wireguard.PeersRepo, + box *secrets.Box, + a *audit.Repo, + nodeID string, + reloader func(context.Context) error, +) *WireguardHandler { + return &WireguardHandler{Ifaces: ifaces, Peers: peers, Box: box, Audit: a, NodeID: nodeID, Reloader: reloader} +} + +func (h *WireguardHandler) reload(ctx context.Context, op string) { + if h.Reloader == nil { + return + } + if err := h.Reloader(ctx); err != nil { + slog.Warn("wireguard: render after mutation failed", "op", op, "error", err) + } +} + +func (h *WireguardHandler) Register(rg *gin.RouterGroup) { + g := rg.Group("/wireguard") + + // Standalone keygen — no DB write. Frontend uses this to fill + // the form when the operator wants a fresh keypair without + // committing. + g.POST("/generate-keypair", h.GenerateKeypair) + + g.GET("/interfaces", h.ListIfaces) + g.POST("/interfaces", h.CreateIface) + g.GET("/interfaces/:id", h.GetIface) + g.PUT("/interfaces/:id", h.UpdateIface) + g.DELETE("/interfaces/:id", h.DeleteIface) + + g.GET("/interfaces/:id/peers", h.ListPeers) + g.POST("/interfaces/:id/peers", h.CreatePeer) + g.GET("/peers/:pid", h.GetPeer) + g.PUT("/peers/:pid", h.UpdatePeer) + g.DELETE("/peers/:pid", h.DeletePeer) + + // Per-peer config download — wg-quick text and QR PNG. The QR + // embeds the same text so mobile apps can import directly. + g.GET("/peers/:pid/config", h.PeerConfig) + g.GET("/peers/:pid/qr", h.PeerQR) +} + +// ── Keygen ──────────────────────────────────────────────────────── + +func (h *WireguardHandler) GenerateKeypair(c *gin.Context) { + kp, err := wireguard.GenerateKeypair() + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{ + "private_key": kp.Private, + "public_key": kp.Public, + }) +} + +// ── Interfaces ──────────────────────────────────────────────────── + +// ifaceCreateReq splits the wire shape from the model so we can +// accept a private key by value (operator paste) or auto-generate +// (operator ticked "generate keypair") without leaking the +// encrypted-bytes field into the JSON contract. +type ifaceCreateReq struct { + Name string `json:"name"` + Mode string `json:"mode"` + AddressCIDR string `json:"address_cidr"` + ListenPort *int `json:"listen_port,omitempty"` + PeerEndpoint *string `json:"peer_endpoint,omitempty"` + PeerPublicKey *string `json:"peer_public_key,omitempty"` + PeerPSK *string `json:"peer_psk,omitempty"` + AllowedIPs *string `json:"allowed_ips,omitempty"` + PersistentKeepalive *int `json:"persistent_keepalive,omitempty"` + MTU *int `json:"mtu,omitempty"` + Role string `json:"role"` + Active bool `json:"active"` + Description *string `json:"description,omitempty"` + + // Either GenerateKeypair=true (server picks one) or PrivateKey + // is filled in by the operator. PublicKey is always derived. + GenerateKeypair bool `json:"generate_keypair,omitempty"` + PrivateKey string `json:"private_key,omitempty"` +} + +func (h *WireguardHandler) CreateIface(c *gin.Context) { + var req ifaceCreateReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + priv := req.PrivateKey + if req.GenerateKeypair || priv == "" { + kp, err := wireguard.GenerateKeypair() + if err != nil { + response.Internal(c, err) + return + } + priv = kp.Private + } + pub, err := wireguard.PublicFromPrivate(priv) + if err != nil { + response.BadRequest(c, fmt.Errorf("private_key: %w", err)) + return + } + encPriv, err := h.Box.Seal([]byte(priv)) + if err != nil { + response.Internal(c, err) + return + } + var encPSK []byte + if req.PeerPSK != nil && *req.PeerPSK != "" { + encPSK, err = h.Box.Seal([]byte(*req.PeerPSK)) + if err != nil { + response.Internal(c, err) + return + } + } + ifc := models.WireguardInterface{ + Name: req.Name, Mode: req.Mode, AddressCIDR: req.AddressCIDR, + ListenPort: req.ListenPort, PublicKey: pub, PrivateKeyEnc: encPriv, + PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey, + PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs, + PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU, + Role: req.Role, Active: req.Active, Description: req.Description, + } + if ifc.Role == "" { + ifc.Role = "wan" + } + out, err := h.Ifaces.Create(c.Request.Context(), ifc) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.iface.create", out.Name, out, h.NodeID) + response.Created(c, out) + h.reload(c.Request.Context(), "iface.create") +} + +// ifaceUpdateReq mirrors ifaceCreateReq but PrivateKey is optional +// — an empty PrivateKey + GenerateKeypair=false leaves the existing +// key untouched. This lets the operator edit metadata (description, +// allowed_ips, etc.) without re-rolling the tunnel keypair. +type ifaceUpdateReq struct { + ifaceCreateReq +} + +func (h *WireguardHandler) UpdateIface(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + var req ifaceUpdateReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + cur, err := h.Ifaces.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, wireguard.ErrIfaceNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + + encPriv := cur.PrivateKeyEnc + pub := cur.PublicKey + if req.GenerateKeypair { + kp, err := wireguard.GenerateKeypair() + if err != nil { + response.Internal(c, err) + return + } + encPriv, err = h.Box.Seal([]byte(kp.Private)) + if err != nil { + response.Internal(c, err) + return + } + pub = kp.Public + } else if req.PrivateKey != "" { + p, err := wireguard.PublicFromPrivate(req.PrivateKey) + if err != nil { + response.BadRequest(c, fmt.Errorf("private_key: %w", err)) + return + } + encPriv, err = h.Box.Seal([]byte(req.PrivateKey)) + if err != nil { + response.Internal(c, err) + return + } + pub = p + } + encPSK := cur.PeerPSKEnc + if req.PeerPSK != nil { + if *req.PeerPSK == "" { + encPSK = nil + } else { + encPSK, err = h.Box.Seal([]byte(*req.PeerPSK)) + if err != nil { + response.Internal(c, err) + return + } + } + } + + ifc := models.WireguardInterface{ + Name: req.Name, Mode: req.Mode, AddressCIDR: req.AddressCIDR, + ListenPort: req.ListenPort, PublicKey: pub, PrivateKeyEnc: encPriv, + PeerEndpoint: req.PeerEndpoint, PeerPublicKey: req.PeerPublicKey, + PeerPSKEnc: encPSK, AllowedIPs: req.AllowedIPs, + PersistentKeepalive: req.PersistentKeepalive, MTU: req.MTU, + Role: req.Role, Active: req.Active, Description: req.Description, + } + if ifc.Role == "" { + ifc.Role = cur.Role + } + out, err := h.Ifaces.Update(c.Request.Context(), id, ifc) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.iface.update", out.Name, out, h.NodeID) + response.OK(c, out) + h.reload(c.Request.Context(), "iface.update") +} + +func (h *WireguardHandler) ListIfaces(c *gin.Context) { + out, err := h.Ifaces.List(c.Request.Context()) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"interfaces": out}) +} + +func (h *WireguardHandler) GetIface(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + x, err := h.Ifaces.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, wireguard.ErrIfaceNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, x) +} + +func (h *WireguardHandler) DeleteIface(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.Ifaces.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, wireguard.ErrIfaceNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.iface.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) + h.reload(c.Request.Context(), "iface.delete") +} + +// ── Peers ───────────────────────────────────────────────────────── + +func (h *WireguardHandler) ListPeers(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + out, err := h.Peers.ListForInterface(c.Request.Context(), id) + if err != nil { + response.Internal(c, err) + return + } + response.OK(c, gin.H{"peers": out}) +} + +type peerReq struct { + Name string `json:"name"` + PublicKey string `json:"public_key,omitempty"` + PrivateKey string `json:"private_key,omitempty"` + PSK *string `json:"psk,omitempty"` + AllowedIPs string `json:"allowed_ips"` + Keepalive *int `json:"keepalive,omitempty"` + Enabled bool `json:"enabled"` + Description *string `json:"description,omitempty"` + + GenerateKeypair bool `json:"generate_keypair,omitempty"` + GeneratePSK bool `json:"generate_psk,omitempty"` +} + +func (h *WireguardHandler) CreatePeer(c *gin.Context) { + ifaceID, ok := parseID(c) + if !ok { + return + } + var req peerReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + pub := req.PublicKey + var encPriv []byte + if req.GenerateKeypair || (req.PublicKey == "" && req.PrivateKey == "") { + kp, err := wireguard.GenerateKeypair() + if err != nil { + response.Internal(c, err) + return + } + pub = kp.Public + encPriv, err = h.Box.Seal([]byte(kp.Private)) + if err != nil { + response.Internal(c, err) + return + } + } else if req.PrivateKey != "" { + p, err := wireguard.PublicFromPrivate(req.PrivateKey) + if err != nil { + response.BadRequest(c, fmt.Errorf("private_key: %w", err)) + return + } + pub = p + encPriv, err = h.Box.Seal([]byte(req.PrivateKey)) + if err != nil { + response.Internal(c, err) + return + } + } + var encPSK []byte + switch { + case req.GeneratePSK: + psk, err := wireguard.GeneratePresharedKey() + if err != nil { + response.Internal(c, err) + return + } + encPSK, err = h.Box.Seal([]byte(psk)) + if err != nil { + response.Internal(c, err) + return + } + case req.PSK != nil && *req.PSK != "": + var err error + encPSK, err = h.Box.Seal([]byte(*req.PSK)) + if err != nil { + response.Internal(c, err) + return + } + } + + p := models.WireguardPeer{ + InterfaceID: ifaceID, + Name: req.Name, + PublicKey: pub, + PrivateKeyEnc: encPriv, + PSKEnc: encPSK, + AllowedIPs: req.AllowedIPs, + Keepalive: req.Keepalive, + Enabled: req.Enabled, + Description: req.Description, + } + out, err := h.Peers.Create(c.Request.Context(), p) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.peer.create", out.Name, out, h.NodeID) + response.Created(c, out) + h.reload(c.Request.Context(), "peer.create") +} + +func (h *WireguardHandler) UpdatePeer(c *gin.Context) { + id := parsePeerID(c) + if id == 0 { + return + } + var req peerReq + if err := c.ShouldBindJSON(&req); err != nil { + response.BadRequest(c, err) + return + } + cur, err := h.Peers.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, wireguard.ErrPeerNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + pub := cur.PublicKey + encPriv := cur.PrivateKeyEnc + if req.GenerateKeypair { + kp, err := wireguard.GenerateKeypair() + if err != nil { + response.Internal(c, err) + return + } + pub = kp.Public + encPriv, err = h.Box.Seal([]byte(kp.Private)) + if err != nil { + response.Internal(c, err) + return + } + } else if req.PrivateKey != "" { + p, err := wireguard.PublicFromPrivate(req.PrivateKey) + if err != nil { + response.BadRequest(c, fmt.Errorf("private_key: %w", err)) + return + } + pub = p + encPriv, err = h.Box.Seal([]byte(req.PrivateKey)) + if err != nil { + response.Internal(c, err) + return + } + } else if req.PublicKey != "" { + pub = req.PublicKey + encPriv = nil + } + encPSK := cur.PSKEnc + if req.GeneratePSK { + psk, err := wireguard.GeneratePresharedKey() + if err != nil { + response.Internal(c, err) + return + } + encPSK, err = h.Box.Seal([]byte(psk)) + if err != nil { + response.Internal(c, err) + return + } + } else if req.PSK != nil { + if *req.PSK == "" { + encPSK = nil + } else { + var err error + encPSK, err = h.Box.Seal([]byte(*req.PSK)) + if err != nil { + response.Internal(c, err) + return + } + } + } + p := models.WireguardPeer{ + Name: req.Name, PublicKey: pub, PrivateKeyEnc: encPriv, PSKEnc: encPSK, + AllowedIPs: req.AllowedIPs, Keepalive: req.Keepalive, + Enabled: req.Enabled, Description: req.Description, + } + out, err := h.Peers.Update(c.Request.Context(), id, p) + if err != nil { + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.peer.update", out.Name, out, h.NodeID) + response.OK(c, out) + h.reload(c.Request.Context(), "peer.update") +} + +func (h *WireguardHandler) GetPeer(c *gin.Context) { + id := parsePeerID(c) + if id == 0 { + return + } + x, err := h.Peers.Get(c.Request.Context(), id) + if err != nil { + if errors.Is(err, wireguard.ErrPeerNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + response.OK(c, x) +} + +func (h *WireguardHandler) DeletePeer(c *gin.Context) { + id := parsePeerID(c) + if id == 0 { + return + } + if err := h.Peers.Delete(c.Request.Context(), id); err != nil { + if errors.Is(err, wireguard.ErrPeerNotFound) { + response.NotFound(c, err) + return + } + response.Internal(c, err) + return + } + _ = h.Audit.Log(c.Request.Context(), actorOf(c), "wireguard.peer.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) + response.NoContent(c) + h.reload(c.Request.Context(), "peer.delete") +} + +// ── Per-peer config download ────────────────────────────────────── + +// peerConfigText assembles a wg-quick-style client config from a +// peer row + its parent interface. Requires the peer to have a +// stored private key (generated server-side); manual-pubkey rows +// can't have a config because we never knew their private half. +func (h *WireguardHandler) peerConfigText(ctx context.Context, peerID int64) (string, error) { + p, err := h.Peers.Get(ctx, peerID) + if err != nil { + return "", err + } + if len(p.PrivateKeyEnc) == 0 { + return "", errors.New("no private key stored for this peer — config download only works for peers whose keypair was generated by edgeguard") + } + priv, err := h.Box.Open(p.PrivateKeyEnc) + if err != nil { + return "", fmt.Errorf("decrypt: %w", err) + } + ifc, err := h.Ifaces.Get(ctx, p.InterfaceID) + if err != nil { + return "", err + } + if ifc.Mode != "server" { + return "", errors.New("config download only available for server-mode interfaces") + } + var b bytes.Buffer + b.WriteString("[Interface]\n") + fmt.Fprintf(&b, "PrivateKey = %s\n", string(priv)) + fmt.Fprintf(&b, "Address = %s\n", p.AllowedIPs) + if p.Keepalive == nil || *p.Keepalive == 0 { + // PersistentKeepalive defaults to 25s for client side so + // NAT traversal stays alive. + b.WriteString("\n") + } + b.WriteString("\n[Peer]\n") + fmt.Fprintf(&b, "PublicKey = %s\n", ifc.PublicKey) + if len(p.PSKEnc) > 0 { + psk, err := h.Box.Open(p.PSKEnc) + if err != nil { + return "", fmt.Errorf("decrypt psk: %w", err) + } + fmt.Fprintf(&b, "PresharedKey = %s\n", string(psk)) + } + // AllowedIPs on the client side is "everything that should go + // through the tunnel". For a server hosting an internal LAN + // this is typically 10.x/8 or the server's address range. We + // default to the iface address (so the client can at least + // reach the gateway) — operator can edit downloaded conf. + fmt.Fprintf(&b, "AllowedIPs = %s\n", ifc.AddressCIDR) + // Endpoint — the operator's public host:port that peers dial. + // We don't know this here (could be a CNAME or behind a load + // balancer); leave a placeholder the operator must fill in. + if ifc.ListenPort != nil { + fmt.Fprintf(&b, "Endpoint = REPLACE_WITH_PUBLIC_HOST:%d\n", *ifc.ListenPort) + } + if p.Keepalive != nil && *p.Keepalive > 0 { + fmt.Fprintf(&b, "PersistentKeepalive = %d\n", *p.Keepalive) + } else { + b.WriteString("PersistentKeepalive = 25\n") + } + return b.String(), nil +} + +func (h *WireguardHandler) PeerConfig(c *gin.Context) { + id := parsePeerID(c) + if id == 0 { + return + } + conf, err := h.peerConfigText(c.Request.Context(), id) + if err != nil { + response.BadRequest(c, err) + return + } + c.Header("Content-Disposition", `attachment; filename="peer-`+strconv.FormatInt(id, 10)+`.conf"`) + c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(conf)) +} + +func (h *WireguardHandler) PeerQR(c *gin.Context) { + id := parsePeerID(c) + if id == 0 { + return + } + conf, err := h.peerConfigText(c.Request.Context(), id) + if err != nil { + response.BadRequest(c, err) + return + } + png, err := qrcode.Encode(conf, qrcode.Medium, 512) + if err != nil { + response.Internal(c, err) + return + } + c.Data(http.StatusOK, "image/png", png) +} + +// parsePeerID parses the :pid path param. Returns 0 on parse error +// after writing a 400 response. +func parsePeerID(c *gin.Context) int64 { + id, err := strconv.ParseInt(c.Param("pid"), 10, 64) + if err != nil { + response.BadRequest(c, errors.New("invalid peer id")) + return 0 + } + return id +} diff --git a/internal/models/wireguard.go b/internal/models/wireguard.go new file mode 100644 index 0000000..e391559 --- /dev/null +++ b/internal/models/wireguard.go @@ -0,0 +1,65 @@ +package models + +import "time" + +// WireguardInterface is the local end of a WireGuard tunnel — server +// (we listen for peers) or client (we dial out to a fixed peer). +// PrivateKey + PeerPSK never appear in JSON; they are handled inside +// the handler as encrypted blobs (sealed via internal/services/secrets). +type WireguardInterface struct { + ID int64 `gorm:"primaryKey" json:"id"` + Name string `gorm:"column:name;uniqueIndex" json:"name"` + Mode string `gorm:"column:mode" json:"mode"` // server|client + AddressCIDR string `gorm:"column:address_cidr" json:"address_cidr"` + ListenPort *int `gorm:"column:listen_port" json:"listen_port,omitempty"` + PublicKey string `gorm:"column:public_key" json:"public_key"` + PeerEndpoint *string `gorm:"column:peer_endpoint" json:"peer_endpoint,omitempty"` + PeerPublicKey *string `gorm:"column:peer_public_key" json:"peer_public_key,omitempty"` + AllowedIPs *string `gorm:"column:allowed_ips" json:"allowed_ips,omitempty"` + PersistentKeepalive *int `gorm:"column:persistent_keepalive" json:"persistent_keepalive,omitempty"` + MTU *int `gorm:"column:mtu" json:"mtu,omitempty"` + Role string `gorm:"column:role" json:"role"` + 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"` + + // PrivateKeyEnc / PeerPSKEnc are loaded from the DB as raw bytes + // — handler never serialises them. JSON tag uses '-' so they + // don't leak into responses if a developer accidentally returns + // the model directly. + PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"` + PeerPSKEnc []byte `gorm:"column:peer_psk_enc" json:"-"` +} + +func (WireguardInterface) TableName() string { return "wireguard_interfaces" } + +// WireguardPeer is a single roster entry on a server-mode interface. +// PrivateKey + PSK are encrypted at-rest and never returned in list +// payloads — only via the explicit /config download endpoint, and +// only once we generated the keypair server-side (nullable). +type WireguardPeer struct { + ID int64 `gorm:"primaryKey" json:"id"` + InterfaceID int64 `gorm:"column:interface_id" json:"interface_id"` + Name string `gorm:"column:name" json:"name"` + PublicKey string `gorm:"column:public_key" json:"public_key"` + AllowedIPs string `gorm:"column:allowed_ips" json:"allowed_ips"` + Keepalive *int `gorm:"column:keepalive" json:"keepalive,omitempty"` + LastHandshake *time.Time `gorm:"column:last_handshake" json:"last_handshake,omitempty"` + TransferRX int64 `gorm:"column:transfer_rx" json:"transfer_rx"` + TransferTX int64 `gorm:"column:transfer_tx" json:"transfer_tx"` + Enabled bool `gorm:"column:enabled" json:"enabled"` + 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"` + + PrivateKeyEnc []byte `gorm:"column:private_key_enc" json:"-"` + PSKEnc []byte `gorm:"column:psk_enc" json:"-"` + + // HasPrivateKey is a derived flag for the UI: "is the QR-code + // download going to work for this peer, or is this a roster row + // where the operator only pasted a pubkey?" + HasPrivateKey bool `gorm:"-" json:"has_private_key"` +} + +func (WireguardPeer) TableName() string { return "wireguard_peers" } diff --git a/internal/services/secrets/secrets.go b/internal/services/secrets/secrets.go new file mode 100644 index 0000000..da9e7b8 --- /dev/null +++ b/internal/services/secrets/secrets.go @@ -0,0 +1,133 @@ +// Package secrets provides envelope encryption for sensitive +// at-rest values (WireGuard private keys, peer PSKs, possibly more +// later). The master key lives in /var/lib/edgeguard/.master_key +// (32 bytes, 0600 root-owned via postinst — but readable by the +// edgeguard user via group), generated lazily on first use if +// missing. +// +// On-disk format per ciphertext: nonce (12 byte) || aes-gcm ciphertext. +// Plaintext is never logged or returned outside this package's +// callers. +package secrets + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" +) + +const masterKeyLen = 32 + +// DefaultMasterKeyPath is the file that holds the box master key. +// Override via the EDGEGUARD_MASTER_KEY env var for tests. +const DefaultMasterKeyPath = "/var/lib/edgeguard/.master_key" + +// Box uses AES-256-GCM with a static master key to seal/unseal +// values. Concurrency-safe; the cipher is initialised once. +type Box struct { + once sync.Once + aead cipher.AEAD + err error + path string +} + +// New returns a Box that loads / lazily creates the master key at +// the given path. Pass empty string to use DefaultMasterKeyPath. +func New(path string) *Box { + if path == "" { + path = DefaultMasterKeyPath + } + return &Box{path: path} +} + +func (b *Box) init() { + b.once.Do(func() { + key, err := loadOrCreateMasterKey(b.path) + if err != nil { + b.err = fmt.Errorf("master key: %w", err) + return + } + blk, err := aes.NewCipher(key) + if err != nil { + b.err = fmt.Errorf("aes cipher: %w", err) + return + } + aead, err := cipher.NewGCM(blk) + if err != nil { + b.err = fmt.Errorf("gcm: %w", err) + return + } + b.aead = aead + }) +} + +// Seal returns nonce||ciphertext. Empty input → empty output (so +// callers can store NULL for "not set" without a special case). +func (b *Box) Seal(plaintext []byte) ([]byte, error) { + if len(plaintext) == 0 { + return nil, nil + } + b.init() + if b.err != nil { + return nil, b.err + } + nonce := make([]byte, b.aead.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("nonce: %w", err) + } + out := b.aead.Seal(nil, nonce, plaintext, nil) + return append(nonce, out...), nil +} + +// Open is the inverse of Seal. Empty input → empty output. +func (b *Box) Open(blob []byte) ([]byte, error) { + if len(blob) == 0 { + return nil, nil + } + b.init() + if b.err != nil { + return nil, b.err + } + ns := b.aead.NonceSize() + if len(blob) < ns+1 { + return nil, errors.New("ciphertext too short") + } + nonce, ct := blob[:ns], blob[ns:] + pt, err := b.aead.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("decrypt: %w", err) + } + return pt, nil +} + +func loadOrCreateMasterKey(path string) ([]byte, error) { + if data, err := os.ReadFile(path); err == nil { + if len(data) != masterKeyLen { + return nil, fmt.Errorf("master key file has wrong length %d, want %d", len(data), masterKeyLen) + } + return data, nil + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + + // Generate. Make sure the parent dir exists; postinst should + // have created /var/lib/edgeguard already, but in dev (`go run` + // without sudo) we create it best-effort. + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("mkdir parent: %w", err) + } + key := make([]byte, masterKeyLen) + if _, err := io.ReadFull(rand.Reader, key); err != nil { + return nil, fmt.Errorf("rand: %w", err) + } + if err := os.WriteFile(path, key, 0o600); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + return key, nil +} diff --git a/internal/services/wireguard/import.go b/internal/services/wireguard/import.go new file mode 100644 index 0000000..8305ef0 --- /dev/null +++ b/internal/services/wireguard/import.go @@ -0,0 +1,346 @@ +package wireguard + +import ( + "bufio" + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" +) + +// ImportResult summarises what an Import call did so the CLI can +// report it back to the operator. +type ImportResult struct { + IfacesAdded int `json:"ifaces_added"` + PeersAdded int `json:"peers_added"` + Skipped []string `json:"skipped,omitempty"` // ifaces already present, with reason +} + +// Importer takes existing /etc/wireguard/*.conf files and translates +// them into wireguard_interfaces + wireguard_peers rows so the +// operator can keep their pre-EdgeGuard tunnels live across the +// migration. Heuristics: +// +// - Iface name = filename without .conf (so wg0.conf → wg0) +// - One [Interface] block becomes the wireguard_interfaces row +// - [Peer] blocks with Endpoint+ no AllowedIPs/0.0.0.0/0 → mode=client +// (rare — a wg conf usually has many peers in server mode) +// - >1 [Peer] block → mode=server, peers go to the peer roster +// - Single [Peer] with Endpoint set → mode=client (the peer is the +// upstream we dial) +// +// Existing iface names in the DB are skipped (idempotent re-run). +type Importer struct { + Ifaces *InterfacesRepo + Peers *PeersRepo + Box *secrets.Box +} + +func NewImporter(ifaces *InterfacesRepo, peers *PeersRepo, box *secrets.Box) *Importer { + return &Importer{Ifaces: ifaces, Peers: peers, Box: box} +} + +func (im *Importer) ImportDir(ctx context.Context, dir string) (*ImportResult, error) { + res := &ImportResult{} + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return res, nil + } + return nil, err + } + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") { + continue + } + path := filepath.Join(dir, e.Name()) + ifaceName := strings.TrimSuffix(e.Name(), ".conf") + // Skip names that violate our regex — operator can rename + // the file and re-run. + if !validIfaceName(ifaceName) { + res.Skipped = append(res.Skipped, ifaceName+" (invalid name)") + continue + } + if err := im.importFile(ctx, ifaceName, path, res); err != nil { + res.Skipped = append(res.Skipped, ifaceName+": "+err.Error()) + } + } + return res, nil +} + +func (im *Importer) importFile(ctx context.Context, ifaceName, path string, res *ImportResult) error { + // Skip if already present. + all, err := im.Ifaces.List(ctx) + if err != nil { + return err + } + for _, x := range all { + if x.Name == ifaceName { + res.Skipped = append(res.Skipped, ifaceName+" (already in DB)") + return nil + } + } + + parsed, err := parseWGConf(path) + if err != nil { + return err + } + if parsed.PrivateKey == "" { + return errors.New("no PrivateKey in [Interface]") + } + if parsed.Address == "" { + return errors.New("no Address in [Interface]") + } + + // Mode heuristic: an [Interface] without ListenPort plus a single + // [Peer] with Endpoint set is a client tunnel. Anything else + // (multiple peers, ListenPort set, peers without Endpoint) we + // treat as server. + mode := "server" + if parsed.ListenPort == 0 && len(parsed.Peers) == 1 && parsed.Peers[0].Endpoint != "" { + mode = "client" + } + + pub, err := PublicFromPrivate(parsed.PrivateKey) + if err != nil { + return fmt.Errorf("derive pubkey: %w", err) + } + encPriv, err := im.Box.Seal([]byte(parsed.PrivateKey)) + if err != nil { + return fmt.Errorf("seal: %w", err) + } + + ifc := models.WireguardInterface{ + Name: ifaceName, + Mode: mode, + AddressCIDR: parsed.Address, + PublicKey: pub, + PrivateKeyEnc: encPriv, + Role: "wan", + Active: true, + MTU: intPtrIfNonzero(parsed.MTU), + } + if mode == "server" { + port := parsed.ListenPort + if port == 0 { + port = 51820 + } + ifc.ListenPort = &port + } else { + // client mode → fold the single peer into the iface row. + p := parsed.Peers[0] + ep := p.Endpoint + pk := p.PublicKey + ifc.PeerEndpoint = &ep + ifc.PeerPublicKey = &pk + if p.AllowedIPs != "" { + ai := p.AllowedIPs + ifc.AllowedIPs = &ai + } + if p.Keepalive > 0 { + k := p.Keepalive + ifc.PersistentKeepalive = &k + } + if p.PSK != "" { + pskEnc, err := im.Box.Seal([]byte(p.PSK)) + if err != nil { + return fmt.Errorf("seal psk: %w", err) + } + ifc.PeerPSKEnc = pskEnc + } + } + + created, err := im.Ifaces.Create(ctx, ifc) + if err != nil { + return fmt.Errorf("insert iface: %w", err) + } + res.IfacesAdded++ + + if mode == "server" { + for i, p := range parsed.Peers { + if p.PublicKey == "" { + res.Skipped = append(res.Skipped, fmt.Sprintf("%s peer #%d (no PublicKey)", ifaceName, i)) + continue + } + peer := models.WireguardPeer{ + InterfaceID: created.ID, + Name: p.Name, + PublicKey: p.PublicKey, + AllowedIPs: p.AllowedIPs, + Enabled: true, + } + if peer.Name == "" { + peer.Name = fmt.Sprintf("imported-%d", i+1) + } + if peer.AllowedIPs == "" { + peer.AllowedIPs = "0.0.0.0/0" + } + if p.Keepalive > 0 { + k := p.Keepalive + peer.Keepalive = &k + } + if p.PSK != "" { + pskEnc, err := im.Box.Seal([]byte(p.PSK)) + if err != nil { + return fmt.Errorf("seal peer psk: %w", err) + } + peer.PSKEnc = pskEnc + } + if _, err := im.Peers.Create(ctx, peer); err != nil { + res.Skipped = append(res.Skipped, fmt.Sprintf("%s peer %s: %v", ifaceName, peer.Name, err)) + continue + } + res.PeersAdded++ + } + } + return nil +} + +// parsedConf is the intermediate shape of a parsed wg-quick file. +type parsedConf struct { + Address string + ListenPort int + PrivateKey string + MTU int + Peers []parsedPeer +} + +type parsedPeer struct { + Name string + PublicKey string + Endpoint string + AllowedIPs string + Keepalive int + PSK string +} + +// parseWGConf is intentionally lenient — it accepts anything wg-quick +// would accept, ignores PostUp/PostDown/Table/SaveConfig since +// edgeguard owns runtime, and collapses comment lines starting with +// '#' into the next [Peer]'s Name (mirrors the wg-easy convention). +func parseWGConf(path string) (*parsedConf, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var ( + out parsedConf + section string + currentPeer *parsedPeer + nextName string + ) + + flushPeer := func() { + if currentPeer != nil { + if currentPeer.Name == "" && nextName != "" { + currentPeer.Name = nextName + } + out.Peers = append(out.Peers, *currentPeer) + currentPeer = nil + } + nextName = "" + } + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + if strings.HasPrefix(line, "#") { + // Comment line; if the next thing we see is a [Peer], + // use this as the peer's name. + nextName = strings.TrimSpace(strings.TrimPrefix(line, "#")) + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + flushPeer() + section = strings.ToLower(strings.Trim(line, "[]")) + if section == "peer" { + currentPeer = &parsedPeer{} + } + continue + } + idx := strings.Index(line, "=") + if idx < 0 { + continue + } + key := strings.TrimSpace(strings.ToLower(line[:idx])) + val := strings.TrimSpace(line[idx+1:]) + switch section { + case "interface": + switch key { + case "address": + out.Address = strings.Split(val, ",")[0] // first addr only + case "listenport": + if n, err := strconv.Atoi(val); err == nil { + out.ListenPort = n + } + case "privatekey": + out.PrivateKey = val + case "mtu": + if n, err := strconv.Atoi(val); err == nil { + out.MTU = n + } + } + case "peer": + if currentPeer == nil { + continue + } + switch key { + case "publickey": + currentPeer.PublicKey = val + case "endpoint": + currentPeer.Endpoint = val + case "allowedips": + currentPeer.AllowedIPs = val + case "persistentkeepalive": + if n, err := strconv.Atoi(val); err == nil { + currentPeer.Keepalive = n + } + case "presharedkey": + currentPeer.PSK = val + } + } + } + flushPeer() + if err := sc.Err(); err != nil { + return nil, err + } + return &out, nil +} + +func validIfaceName(s string) bool { + if len(s) < 2 || len(s) > 15 { + return false + } + if !strings.HasPrefix(s, "wg") { + return false + } + for _, r := range s[2:] { + switch { + case r >= 'a' && r <= 'z', + r >= '0' && r <= '9', + r == '-': + default: + return false + } + } + return true +} + +func intPtrIfNonzero(n int) *int { + if n == 0 { + return nil + } + return &n +} diff --git a/internal/services/wireguard/interfaces.go b/internal/services/wireguard/interfaces.go new file mode 100644 index 0000000..e24f7cd --- /dev/null +++ b/internal/services/wireguard/interfaces.go @@ -0,0 +1,118 @@ +package wireguard + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrIfaceNotFound = errors.New("wireguard interface not found") + +type InterfacesRepo struct { + Pool *pgxpool.Pool +} + +func NewInterfacesRepo(pool *pgxpool.Pool) *InterfacesRepo { return &InterfacesRepo{Pool: pool} } + +const ifaceBaseSelect = ` +SELECT id, name, mode, address_cidr, listen_port, public_key, private_key_enc, + peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive, + mtu, role, active, description, created_at, updated_at +FROM wireguard_interfaces +` + +func (r *InterfacesRepo) List(ctx context.Context) ([]models.WireguardInterface, error) { + rows, err := r.Pool.Query(ctx, ifaceBaseSelect+" ORDER BY name ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.WireguardInterface, 0, 4) + for rows.Next() { + i, err := scanIface(rows) + if err != nil { + return nil, err + } + out = append(out, *i) + } + return out, rows.Err() +} + +func (r *InterfacesRepo) Get(ctx context.Context, id int64) (*models.WireguardInterface, error) { + row := r.Pool.QueryRow(ctx, ifaceBaseSelect+" WHERE id = $1", id) + i, err := scanIface(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrIfaceNotFound + } + return nil, err + } + return i, nil +} + +func (r *InterfacesRepo) Create(ctx context.Context, i models.WireguardInterface) (*models.WireguardInterface, error) { + row := r.Pool.QueryRow(ctx, ` +INSERT INTO wireguard_interfaces ( + name, mode, address_cidr, listen_port, public_key, private_key_enc, + peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive, + mtu, role, active, description +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) +RETURNING id, name, mode, address_cidr, listen_port, public_key, private_key_enc, + peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive, + mtu, role, active, description, created_at, updated_at`, + i.Name, i.Mode, i.AddressCIDR, i.ListenPort, i.PublicKey, i.PrivateKeyEnc, + i.PeerEndpoint, i.PeerPublicKey, i.PeerPSKEnc, i.AllowedIPs, i.PersistentKeepalive, + i.MTU, i.Role, i.Active, i.Description) + return scanIface(row) +} + +func (r *InterfacesRepo) Update(ctx context.Context, id int64, i models.WireguardInterface) (*models.WireguardInterface, error) { + row := r.Pool.QueryRow(ctx, ` +UPDATE wireguard_interfaces SET + name = $1, mode = $2, address_cidr = $3, listen_port = $4, public_key = $5, + private_key_enc = $6, peer_endpoint = $7, peer_public_key = $8, peer_psk_enc = $9, + allowed_ips = $10, persistent_keepalive = $11, mtu = $12, role = $13, + active = $14, description = $15, updated_at = NOW() +WHERE id = $16 +RETURNING id, name, mode, address_cidr, listen_port, public_key, private_key_enc, + peer_endpoint, peer_public_key, peer_psk_enc, allowed_ips, persistent_keepalive, + mtu, role, active, description, created_at, updated_at`, + i.Name, i.Mode, i.AddressCIDR, i.ListenPort, i.PublicKey, i.PrivateKeyEnc, + i.PeerEndpoint, i.PeerPublicKey, i.PeerPSKEnc, i.AllowedIPs, i.PersistentKeepalive, + i.MTU, i.Role, i.Active, i.Description, id) + out, err := scanIface(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrIfaceNotFound + } + return nil, err + } + return out, nil +} + +func (r *InterfacesRepo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM wireguard_interfaces WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrIfaceNotFound + } + return nil +} + +func scanIface(row interface{ Scan(...any) error }) (*models.WireguardInterface, error) { + var i models.WireguardInterface + if err := row.Scan( + &i.ID, &i.Name, &i.Mode, &i.AddressCIDR, &i.ListenPort, &i.PublicKey, &i.PrivateKeyEnc, + &i.PeerEndpoint, &i.PeerPublicKey, &i.PeerPSKEnc, &i.AllowedIPs, &i.PersistentKeepalive, + &i.MTU, &i.Role, &i.Active, &i.Description, &i.CreatedAt, &i.UpdatedAt, + ); err != nil { + return nil, err + } + return &i, nil +} diff --git a/internal/services/wireguard/keys.go b/internal/services/wireguard/keys.go new file mode 100644 index 0000000..121a5b7 --- /dev/null +++ b/internal/services/wireguard/keys.go @@ -0,0 +1,77 @@ +// Package wireguard implements the persistence + lifecycle of +// WireGuard tunnels (server + client mode) and their peer roster. +// Sub-files split by concern: +// +// keys.go — Curve25519 keypair generation + parse helpers +// interfaces.go — wireguard_interfaces CRUD +// peers.go — wireguard_peers CRUD +// import.go — read /etc/wireguard/*.conf into the DB +package wireguard + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + + "golang.org/x/crypto/curve25519" +) + +// Keypair holds a base64-encoded WireGuard keypair (the wire format +// wg-quick prints — 32 raw bytes, base64 standard padding included). +type Keypair struct { + Private string + Public string +} + +// GenerateKeypair returns a fresh Curve25519 keypair, clamped per +// WireGuard's spec, base64-encoded. +func GenerateKeypair() (*Keypair, error) { + priv := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, priv); err != nil { + return nil, fmt.Errorf("rand: %w", err) + } + // WireGuard / curve25519 clamping: clear the lowest 3 bits of + // priv[0], clear the highest bit and set the second-highest of + // priv[31]. Required to make the scalar a valid Curve25519 + // private key. + priv[0] &= 248 + priv[31] &= 127 + priv[31] |= 64 + pub, err := curve25519.X25519(priv, curve25519.Basepoint) + if err != nil { + return nil, fmt.Errorf("derive pub: %w", err) + } + return &Keypair{ + Private: base64.StdEncoding.EncodeToString(priv), + Public: base64.StdEncoding.EncodeToString(pub), + }, nil +} + +// GeneratePresharedKey returns a fresh 32-byte PSK as base64. +func GeneratePresharedKey() (string, error) { + psk := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, psk); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(psk), nil +} + +// PublicFromPrivate derives the matching pubkey for a base64 private +// key — used by the import path where the operator hands us a +// private key from an existing wg-quick config. +func PublicFromPrivate(privB64 string) (string, error) { + priv, err := base64.StdEncoding.DecodeString(privB64) + if err != nil { + return "", fmt.Errorf("decode private key: %w", err) + } + if len(priv) != 32 { + return "", errors.New("private key must be 32 bytes") + } + pub, err := curve25519.X25519(priv, curve25519.Basepoint) + if err != nil { + return "", fmt.Errorf("derive pub: %w", err) + } + return base64.StdEncoding.EncodeToString(pub), nil +} diff --git a/internal/services/wireguard/peers.go b/internal/services/wireguard/peers.go new file mode 100644 index 0000000..c52d392 --- /dev/null +++ b/internal/services/wireguard/peers.go @@ -0,0 +1,132 @@ +package wireguard + +import ( + "context" + "errors" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" +) + +var ErrPeerNotFound = errors.New("wireguard peer not found") + +type PeersRepo struct { + Pool *pgxpool.Pool +} + +func NewPeersRepo(pool *pgxpool.Pool) *PeersRepo { return &PeersRepo{Pool: pool} } + +const peerBaseSelect = ` +SELECT id, interface_id, name, public_key, private_key_enc, psk_enc, + allowed_ips, keepalive, last_handshake, transfer_rx, transfer_tx, + enabled, description, created_at, updated_at +FROM wireguard_peers +` + +func (r *PeersRepo) ListForInterface(ctx context.Context, ifaceID int64) ([]models.WireguardPeer, error) { + rows, err := r.Pool.Query(ctx, peerBaseSelect+" WHERE interface_id = $1 ORDER BY name ASC", ifaceID) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.WireguardPeer, 0, 8) + for rows.Next() { + p, err := scanPeer(rows) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +func (r *PeersRepo) ListAll(ctx context.Context) ([]models.WireguardPeer, error) { + rows, err := r.Pool.Query(ctx, peerBaseSelect+" ORDER BY interface_id, name ASC") + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]models.WireguardPeer, 0, 16) + for rows.Next() { + p, err := scanPeer(rows) + if err != nil { + return nil, err + } + out = append(out, *p) + } + return out, rows.Err() +} + +func (r *PeersRepo) Get(ctx context.Context, id int64) (*models.WireguardPeer, error) { + row := r.Pool.QueryRow(ctx, peerBaseSelect+" WHERE id = $1", id) + p, err := scanPeer(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrPeerNotFound + } + return nil, err + } + return p, nil +} + +func (r *PeersRepo) Create(ctx context.Context, p models.WireguardPeer) (*models.WireguardPeer, error) { + row := r.Pool.QueryRow(ctx, ` +INSERT INTO wireguard_peers ( + interface_id, name, public_key, private_key_enc, psk_enc, allowed_ips, + keepalive, enabled, description +) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) +RETURNING id, interface_id, name, public_key, private_key_enc, psk_enc, + allowed_ips, keepalive, last_handshake, transfer_rx, transfer_tx, + enabled, description, created_at, updated_at`, + p.InterfaceID, p.Name, p.PublicKey, p.PrivateKeyEnc, p.PSKEnc, p.AllowedIPs, + p.Keepalive, p.Enabled, p.Description) + return scanPeer(row) +} + +func (r *PeersRepo) Update(ctx context.Context, id int64, p models.WireguardPeer) (*models.WireguardPeer, error) { + row := r.Pool.QueryRow(ctx, ` +UPDATE wireguard_peers SET + name = $1, public_key = $2, private_key_enc = $3, psk_enc = $4, + allowed_ips = $5, keepalive = $6, enabled = $7, description = $8, + updated_at = NOW() +WHERE id = $9 +RETURNING id, interface_id, name, public_key, private_key_enc, psk_enc, + allowed_ips, keepalive, last_handshake, transfer_rx, transfer_tx, + enabled, description, created_at, updated_at`, + p.Name, p.PublicKey, p.PrivateKeyEnc, p.PSKEnc, + p.AllowedIPs, p.Keepalive, p.Enabled, p.Description, id) + out, err := scanPeer(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrPeerNotFound + } + return nil, err + } + return out, nil +} + +func (r *PeersRepo) Delete(ctx context.Context, id int64) error { + tag, err := r.Pool.Exec(ctx, `DELETE FROM wireguard_peers WHERE id = $1`, id) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrPeerNotFound + } + return nil +} + +func scanPeer(row interface{ Scan(...any) error }) (*models.WireguardPeer, error) { + var p models.WireguardPeer + if err := row.Scan( + &p.ID, &p.InterfaceID, &p.Name, &p.PublicKey, &p.PrivateKeyEnc, &p.PSKEnc, + &p.AllowedIPs, &p.Keepalive, &p.LastHandshake, &p.TransferRX, &p.TransferTX, + &p.Enabled, &p.Description, &p.CreatedAt, &p.UpdatedAt, + ); err != nil { + return nil, err + } + p.HasPrivateKey = len(p.PrivateKeyEnc) > 0 + return &p, nil +} diff --git a/internal/wireguard/systemd.go b/internal/wireguard/systemd.go new file mode 100644 index 0000000..dcda61d --- /dev/null +++ b/internal/wireguard/systemd.go @@ -0,0 +1,35 @@ +package wireguard + +import ( + "fmt" + "os/exec" +) + +// wg-quick is managed via systemd unit instances (wg-quick@). +// Reload-via-syncconf would be cheaper (no link flap) but needs more +// per-change diffing — for v1 we restart the unit, which takes ~1s +// and re-establishes peers cleanly. The sudoers entry shipped in +// postinst whitelists exactly these three commands. + +func startWGQuick(iface string) error { + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "start", "wg-quick@"+iface+".service") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("systemctl start wg-quick@%s: %w: %s", iface, err, string(out)) + } + return nil +} + +func restartWGQuick(iface string) error { + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "restart", "wg-quick@"+iface+".service") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("systemctl restart wg-quick@%s: %w: %s", iface, err, string(out)) + } + return nil +} + +func stopWGQuick(iface string) error { + cmd := exec.Command("sudo", "-n", "/usr/bin/systemctl", "stop", "wg-quick@"+iface+".service") + // Ignore failures — unit may not exist. + _ = cmd.Run() + return nil +} diff --git a/internal/wireguard/wireguard.go b/internal/wireguard/wireguard.go index 96f7995..330650e 100644 --- a/internal/wireguard/wireguard.go +++ b/internal/wireguard/wireguard.go @@ -1,19 +1,165 @@ -// Package wireguard will render /etc/edgeguard/wireguard/wg0.conf in -// Phase 3 (and run `wg syncconf` on reload). v1 ships a stub. +// Package wireguard renders /etc/edgeguard/wireguard/.conf +// from the relational state in PG (wireguard_interfaces + +// wireguard_peers) and brings the corresponding wg-quick@ +// service up. Each iface gets its own conf file; the renderer is +// idempotent — running it twice produces the same files and only +// reloads wg if the contents actually changed (mtime + content +// compare). package wireguard import ( + "bytes" "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" - "git.netcell-it.de/projekte/edgeguard-native/internal/configgen" + "github.com/jackc/pgx/v5/pgxpool" + + "git.netcell-it.de/projekte/edgeguard-native/internal/models" + "git.netcell-it.de/projekte/edgeguard-native/internal/services/secrets" + wgsvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/wireguard" ) -type Generator struct{} +const ConfDir = "/etc/edgeguard/wireguard" -func New() *Generator { return &Generator{} } +type Generator struct { + Pool *pgxpool.Pool + Box *secrets.Box + Ifaces *wgsvc.InterfacesRepo + Peers *wgsvc.PeersRepo +} + +func New(pool *pgxpool.Pool, box *secrets.Box) *Generator { + return &Generator{ + Pool: pool, + Box: box, + Ifaces: wgsvc.NewInterfacesRepo(pool), + Peers: wgsvc.NewPeersRepo(pool), + } +} func (g *Generator) Name() string { return "wireguard" } func (g *Generator) Render(ctx context.Context) error { - return configgen.ErrNotImplemented + if err := os.MkdirAll(ConfDir, 0o700); err != nil { + return fmt.Errorf("mkdir %s: %w", ConfDir, err) + } + + ifs, err := g.Ifaces.List(ctx) + if err != nil { + return fmt.Errorf("list ifaces: %w", err) + } + + wantNames := map[string]bool{} + for _, ifc := range ifs { + if !ifc.Active { + continue + } + wantNames[ifc.Name] = true + if err := g.renderIface(ctx, ifc); err != nil { + return fmt.Errorf("iface %s: %w", ifc.Name, err) + } + } + + // Tidy up: any .conf in ConfDir that doesn't correspond to an + // active iface gets removed and its wg-quick@ stopped — keeps + // kernel state in sync after a delete. + entries, err := os.ReadDir(ConfDir) + if err == nil { + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".conf") { + continue + } + ifaceName := strings.TrimSuffix(e.Name(), ".conf") + if wantNames[ifaceName] { + continue + } + _ = os.Remove(filepath.Join(ConfDir, e.Name())) + _ = stopWGQuick(ifaceName) + } + } + return nil +} + +func (g *Generator) renderIface(ctx context.Context, ifc models.WireguardInterface) error { + priv, err := g.Box.Open(ifc.PrivateKeyEnc) + if err != nil { + return fmt.Errorf("decrypt private key: %w", err) + } + var body bytes.Buffer + body.WriteString("# Generated by edgeguard — do not edit by hand.\n") + body.WriteString("[Interface]\n") + fmt.Fprintf(&body, "Address = %s\n", ifc.AddressCIDR) + fmt.Fprintf(&body, "PrivateKey = %s\n", string(priv)) + if ifc.ListenPort != nil { + fmt.Fprintf(&body, "ListenPort = %d\n", *ifc.ListenPort) + } + if ifc.MTU != nil { + fmt.Fprintf(&body, "MTU = %d\n", *ifc.MTU) + } + body.WriteString("\n") + + switch ifc.Mode { + case "client": + if ifc.PeerPublicKey == nil || ifc.PeerEndpoint == nil { + return errors.New("client iface missing peer_endpoint or peer_public_key") + } + body.WriteString("[Peer]\n") + fmt.Fprintf(&body, "PublicKey = %s\n", *ifc.PeerPublicKey) + fmt.Fprintf(&body, "Endpoint = %s\n", *ifc.PeerEndpoint) + if ifc.AllowedIPs != nil && *ifc.AllowedIPs != "" { + fmt.Fprintf(&body, "AllowedIPs = %s\n", *ifc.AllowedIPs) + } else { + body.WriteString("AllowedIPs = 0.0.0.0/0,::/0\n") + } + if ifc.PersistentKeepalive != nil { + fmt.Fprintf(&body, "PersistentKeepalive = %d\n", *ifc.PersistentKeepalive) + } + if len(ifc.PeerPSKEnc) > 0 { + psk, err := g.Box.Open(ifc.PeerPSKEnc) + if err != nil { + return fmt.Errorf("decrypt peer psk: %w", err) + } + fmt.Fprintf(&body, "PresharedKey = %s\n", string(psk)) + } + case "server": + peers, err := g.Peers.ListForInterface(ctx, ifc.ID) + if err != nil { + return fmt.Errorf("list peers: %w", err) + } + sort.Slice(peers, func(i, j int) bool { return peers[i].Name < peers[j].Name }) + for _, p := range peers { + if !p.Enabled { + continue + } + body.WriteString("[Peer]\n") + fmt.Fprintf(&body, "# %s\n", p.Name) + fmt.Fprintf(&body, "PublicKey = %s\n", p.PublicKey) + fmt.Fprintf(&body, "AllowedIPs = %s\n", p.AllowedIPs) + if p.Keepalive != nil { + fmt.Fprintf(&body, "PersistentKeepalive = %d\n", *p.Keepalive) + } + if len(p.PSKEnc) > 0 { + psk, err := g.Box.Open(p.PSKEnc) + if err != nil { + return fmt.Errorf("decrypt peer %s psk: %w", p.Name, err) + } + fmt.Fprintf(&body, "PresharedKey = %s\n", string(psk)) + } + body.WriteString("\n") + } + } + + path := filepath.Join(ConfDir, ifc.Name+".conf") + if existing, err := os.ReadFile(path); err == nil && bytes.Equal(existing, body.Bytes()) { + return startWGQuick(ifc.Name) + } + if err := os.WriteFile(path, body.Bytes(), 0o600); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return restartWGQuick(ifc.Name) } diff --git a/management-ui/package.json b/management-ui/package.json index 2d6110e..da15407 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.8", + "version": "1.0.11", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 4d973f2..6256558 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -20,6 +20,7 @@ const NetworksPage = lazy(() => import('./pages/Networks')) const IPAddressesPage = lazy(() => import('./pages/IPAddresses')) const SSLPage = lazy(() => import('./pages/SSL')) const FirewallPage = lazy(() => import('./pages/Firewall')) +const WireguardPage = lazy(() => import('./pages/Wireguard')) const ClusterPage = lazy(() => import('./pages/Cluster')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -99,6 +100,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/ActionButtons.tsx b/management-ui/src/components/ActionButtons.tsx new file mode 100644 index 0000000..c995e9c --- /dev/null +++ b/management-ui/src/components/ActionButtons.tsx @@ -0,0 +1,64 @@ +import { Button, Popconfirm, Space, Tooltip } from 'antd' +import { DeleteOutlined, EditOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' + +// ActionButtons is the standard "Edit / Delete" pair used at the +// end of every CRUD table row. Centralising it means we only style +// the action column once across the app. +// +// Either prop may be omitted to suppress that button — useful for +// rows that aren't editable (e.g. builtin services). +interface ActionButtonsProps { + onEdit?: () => void + onDelete?: () => void + deleteConfirm?: string + editTooltip?: string + deleteTooltip?: string + editDisabled?: boolean + deleteDisabled?: boolean + editDisabledReason?: string + deleteDisabledReason?: string +} + +export default function ActionButtons({ + onEdit, onDelete, + deleteConfirm, + editTooltip, deleteTooltip, + editDisabled, deleteDisabled, + editDisabledReason, deleteDisabledReason, +}: ActionButtonsProps) { + const { t } = useTranslation() + return ( + + {onEdit && ( + + + } + /> + + { setEditing(null); setCreating(false); form.resetFields() }} + onOk={() => { void form.submit() }} + confirmLoading={upsert.isPending} + width={680} + destroyOnClose + > +
upsert.mutate(v)}> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {t('wg.peer.keys')}}> + + + + p.generate_keypair !== c.generate_keypair}> + {({ getFieldValue }) => !getFieldValue('generate_keypair') && ( + + + + )} + + + + + +
+
+ + setQrPeer(null)} + footer={null} + width={420} + > + {qrPeer && ( + + WireGuard QR + + {t('wg.peer.qrHint')} + + + + )} + + + ) +} diff --git a/management-ui/src/pages/Wireguard/index.tsx b/management-ui/src/pages/Wireguard/index.tsx new file mode 100644 index 0000000..34a3146 --- /dev/null +++ b/management-ui/src/pages/Wireguard/index.tsx @@ -0,0 +1,39 @@ +import { Tabs } from 'antd' +import { ApiOutlined, GlobalOutlined, ThunderboltOutlined } from '@ant-design/icons' +import { useTranslation } from 'react-i18next' + +import PageHeader from '../../components/PageHeader' +import ServersTab from './Servers' +import ClientsTab from './Clients' + +// /vpn/wireguard — two tabs (Server, Client). Each is independent; +// they share types but not state. Server-tab opens a peer-roster +// drawer per server, Client-tab manages outbound tunnels with a +// fixed upstream peer. +export default function WireguardPage() { + const { t } = useTranslation() + return ( +
+ } + title={t('wg.title')} + subtitle={t('wg.intro')} + /> + {t('wg.tabs.servers')}, + children: , + }, + { + key: 'clients', + label: {t('wg.tabs.clients')}, + children: , + }, + ]} + /> +
+ ) +} diff --git a/management-ui/src/pages/Wireguard/types.ts b/management-ui/src/pages/Wireguard/types.ts new file mode 100644 index 0000000..7ba53ea --- /dev/null +++ b/management-ui/src/pages/Wireguard/types.ts @@ -0,0 +1,37 @@ +// Shared types for /vpn/wireguard tabs. + +export interface WGInterface { + id: number + name: string + mode: 'server' | 'client' + address_cidr: string + listen_port?: number | null + public_key: string + peer_endpoint?: string | null + peer_public_key?: string | null + allowed_ips?: string | null + persistent_keepalive?: number | null + mtu?: number | null + role: string + active: boolean + description?: string | null + created_at: string + updated_at: string +} + +export interface WGPeer { + id: number + interface_id: number + name: string + public_key: string + allowed_ips: string + keepalive?: number | null + last_handshake?: string | null + transfer_rx: number + transfer_tx: number + enabled: boolean + description?: string | null + has_private_key: boolean + created_at: string + updated_at: string +} diff --git a/management-ui/src/styles/enterprise.css b/management-ui/src/styles/enterprise.css index ac8d010..34b763b 100644 --- a/management-ui/src/styles/enterprise.css +++ b/management-ui/src/styles/enterprise.css @@ -2708,3 +2708,41 @@ h1, h2, h3, h4, h5, h6 { gap: 8px; } .learning-neural-name { font-family: 'JetBrains Mono', monospace; } + +/* ── PageHeader (proxy-lb-waf-style) ───────────────────────────── */ +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 20px; +} +.page-header-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.page-header-title { + margin: 0 !important; + display: flex; + align-items: center; + gap: 8px; + font-size: 18px !important; + font-weight: 600 !important; +} +.page-header-title .page-header-icon { + color: #0EA5E9; + display: inline-flex; + font-size: 18px; +} +.page-header-subtitle { + font-size: 13px; + color: #64748B; +} + +/* ── DataTable toolbar row ─────────────────────────────────────── */ +.datatable-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 12px; + align-items: center; + justify-content: space-between; +} diff --git a/packaging/debian/edgeguard-api/DEBIAN/postinst b/packaging/debian/edgeguard-api/DEBIAN/postinst index 8cc3b44..bc6e34c 100755 --- a/packaging/debian/edgeguard-api/DEBIAN/postinst +++ b/packaging/debian/edgeguard-api/DEBIAN/postinst @@ -39,6 +39,12 @@ case "$1" in edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl reload haproxy.service edgeguard ALL=(root) NOPASSWD: /bin/systemctl reload haproxy.service edgeguard ALL=(root) NOPASSWD: /usr/sbin/nft -f /etc/edgeguard/nftables.d/ruleset.nft +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl restart wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl start wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl restart wg-quick@*.service +edgeguard ALL=(root) NOPASSWD: /bin/systemctl stop wg-quick@*.service SUDOERS chmod 0440 /etc/sudoers.d/edgeguard