diff --git a/app/Dockerfile b/app/Dockerfile
new file mode 100644
index 0000000..4ab7694
--- /dev/null
+++ b/app/Dockerfile
@@ -0,0 +1,15 @@
+# Build stage
+FROM golang:1.20-alpine AS builder
+WORKDIR /app
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN go build -o wgrplane ./...
+
+# Run stage
+FROM alpine:3.18
+RUN apk --no-cache add ca-certificates
+WORKDIR /app
+COPY --from=builder /app/wgrplane .
+EXPOSE 8080
+CMD ["./wgrplane"]
diff --git a/app/active.en.json b/app/active.en.json
new file mode 100644
index 0000000..fca757d
--- /dev/null
+++ b/app/active.en.json
@@ -0,0 +1,4 @@
+{
+ "greeting": "Hello",
+ "farewell": "Goodbye"
+}
diff --git a/app/active.id.json b/app/active.id.json
new file mode 100644
index 0000000..d4e6494
--- /dev/null
+++ b/app/active.id.json
@@ -0,0 +1,4 @@
+{
+ "greeting": "Halo",
+ "farewell": "Selamat tinggal"
+}
diff --git a/app/active.zh.json b/app/active.zh.json
new file mode 100644
index 0000000..6bb33a1
--- /dev/null
+++ b/app/active.zh.json
@@ -0,0 +1,4 @@
+{
+ "greeting": "你好",
+ "farewell": "再见"
+}
diff --git a/app/auth.go b/app/auth.go
new file mode 100644
index 0000000..06e75a6
--- /dev/null
+++ b/app/auth.go
@@ -0,0 +1,146 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "os"
+ "strings"
+ "time"
+
+ jwt "github.com/golang-jwt/jwt/v5"
+ "github.com/pquerna/otp/totp"
+)
+
+// Simple in-memory storage for TOTPs per user. In production, use a DB.
+var totpSecrets = map[string]string{}
+
+// API key (default) - can be overridden by WG_API_KEY env var.
+var apiKey string
+
+// JWT secret (default) - can be overridden by JWT_SECRET env var.
+var jwtSecret string
+
+func init() {
+ apiKey = os.Getenv("WG_API_KEY")
+ if apiKey == "" {
+ apiKey = "test-api-key" // default for testing
+ }
+ jwtSecret = os.Getenv("JWT_SECRET")
+ if jwtSecret == "" {
+ jwtSecret = "secret" // default for testing
+ }
+ // Preload a test user so JWTs can be generated in tests if needed
+ if _, ok := totpSecrets["test"]; !ok {
+ // generate a random-looking secret for test user if desired
+ // but we won't force it here; user can call /auth/setup-totp?user=test
+ }
+}
+
+type Claims struct {
+ Username string `json:"username"`
+ jwt.RegisteredClaims
+}
+
+// generateJWT creates a short-lived JWT for a given username
+func generateJWT(username string) (string, error) {
+ claims := &Claims{
+ Username: username,
+ RegisteredClaims: jwt.RegisteredClaims{
+ Issuer: "wgrplane",
+ ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
+ },
+ }
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ return token.SignedString([]byte(jwtSecret))
+}
+
+func parseJWT(tokenStr string) (*Claims, error) {
+ token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(token *jwt.Token) (interface{}, error) {
+ return []byte(jwtSecret), nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if claims, ok := token.Claims.(*Claims); ok && token.Valid {
+ return claims, nil
+ }
+ return nil, fmt.Errorf("invalid token")
+}
+
+// totpSetupHandler returns a new TOTP secret and provisioning URI for a user
+func totpSetupHandler(w http.ResponseWriter, r *http.Request) {
+ user := r.URL.Query().Get("user")
+ if user == "" {
+ http.Error(w, "missing user", http.StatusBadRequest)
+ return
+ }
+ key, err := totp.Generate(totp.GenerateOpts{
+ Issuer: "WGRplane",
+ AccountName: user,
+ })
+ if err != nil {
+ http.Error(w, "failed to generate secret", http.StatusInternalServerError)
+ return
+ }
+ secret := key.Secret()
+ totpSecrets[user] = secret
+ resp := map[string]string{
+ "secret": secret,
+ "provisioning_uri": key.URL(),
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _ = json.NewEncoder(w).Encode(resp)
+}
+
+// AuthMiddleware protects selected routes with API Key or JWT + optional TOTP
+func AuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // 1) API Key path
+ if key := r.Header.Get("wg-rplane-datadunia"); key != "" {
+ if subtleConstantTimeEquals(key, apiKey) {
+ // If user has a TOTP, require current OTP in header
+ // The username isn't known from API Key alone; skip TOTP check here
+ next.ServeHTTP(w, r)
+ return
+ }
+ }
+
+ // 2) JWT path
+ authHeader := r.Header.Get("Authorization")
+ if strings.HasPrefix(authHeader, "Bearer ") {
+ token := strings.TrimPrefix(authHeader, "Bearer ")
+ claims, err := parseJWT(token)
+ if err != nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+ // If user has a TOTp secret, verify it
+ if secret, ok := totpSecrets[claims.Username]; ok {
+ otp := r.Header.Get("X-TOTP")
+ if otp == "" || !totp.Validate(otp, secret) {
+ http.Error(w, "Unauthorized (TOTp)", http.StatusUnauthorized)
+ return
+ }
+ }
+ // Attach username to context for downstream handlers if needed
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ // No valid auth provided
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ })
+}
+
+// helper for constant-time string comparison
+func subtleConstantTimeEquals(a, b string) bool {
+ if len(a) != len(b) {
+ return false
+ }
+ var diff byte
+ for i := 0; i < len(a); i++ {
+ diff |= a[i] ^ b[i]
+ }
+ return diff == 0
+}
diff --git a/app/docs/docs.go b/app/docs/docs.go
new file mode 100644
index 0000000..57406ab
--- /dev/null
+++ b/app/docs/docs.go
@@ -0,0 +1,1244 @@
+// Package docs Code generated by swaggo/swag. DO NOT EDIT
+package docs
+
+import "github.com/swaggo/swag"
+
+const docTemplate = `{
+ "schemes": {{ marshal .Schemes }},
+ "swagger": "2.0",
+ "info": {
+ "description": "{{escape .Description}}",
+ "title": "{{.Title}}",
+ "contact": {
+ "name": "API Support"
+ },
+ "version": "{{.Version}}"
+ },
+ "host": "{{.Host}}",
+ "basePath": "{{.BasePath}}",
+ "paths": {
+ "/api/peers/{id}": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Updates a peer and computes diffs to apply incremental nftables changes.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Update a peer",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Updated peer fields",
+ "name": "peer",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Deletes a peer, cleans up nftables rules, and triggers webhooks.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Delete a peer",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No content"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/peers/{id}/config": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Downloads the WireGuard .conf file for a specific peer.",
+ "produces": [
+ "text/plain"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Get peer config",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "WireGuard configuration file",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/peers/{id}/qrcode": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns a QR code PNG image of the peer config for mobile import.",
+ "produces": [
+ "image/png"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Get peer QR code",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "QR code PNG image",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns a list of all registered WireGuard servers.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "List all servers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Server"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Creates a new WireGuard server with the provided configuration.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Create a server",
+ "parameters": [
+ {
+ "description": "Server configuration",
+ "name": "server",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns a single WireGuard server by its ID.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Get a server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Updates fields of an existing WireGuard server.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Update a server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Updated server fields",
+ "name": "server",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Deletes a WireGuard server and cascade-removes its peers and webhooks.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Delete a server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No content"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}/peers": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns all peers belonging to a specific WireGuard server.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "List server peers",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Creates a new WireGuard peer. Applies nftables rules in forward mode or triggers webhooks in standalone mode.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Create a peer",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Peer configuration",
+ "name": "peer",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}/stats": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns per-server statistics including peer count and webhook count.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "stats"
+ ],
+ "summary": "Get server stats",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}/webhooks": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns all webhooks configured for a specific WireGuard server.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "webhooks"
+ ],
+ "summary": "List server webhooks",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Creates a new webhook configuration for a WireGuard server.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "webhooks"
+ ],
+ "summary": "Create a webhook",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Webhook configuration",
+ "name": "webhook",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/settings/smtp": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns the current SMTP configuration for email notifications.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "settings"
+ ],
+ "summary": "Get SMTP settings",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.SMTPSettings"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Saves or updates SMTP configuration for email notifications.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "settings"
+ ],
+ "summary": "Save SMTP settings",
+ "parameters": [
+ {
+ "description": "SMTP configuration",
+ "name": "settings",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.SMTPSettings"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.SMTPSettings"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/stats": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns global statistics including total servers, peers, and webhooks.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "stats"
+ ],
+ "summary": "Get global stats",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/webhooks/{id}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Deletes a webhook by its ID.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "webhooks"
+ ],
+ "summary": "Delete a webhook",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Webhook ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No content"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "main.Peer": {
+ "type": "object",
+ "properties": {
+ "allowAccess": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "allowInternet": {
+ "type": "boolean"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "currentDataUsageBytes": {
+ "description": "CurrentDataUsageBytes tracks the amount of data used by this peer (in bytes)",
+ "type": "integer",
+ "format": "int64"
+ },
+ "dataLimitGB": {
+ "description": "DataLimitGB defines the monthly data limit per peer (in GB). 0 means unlimited.",
+ "type": "integer",
+ "format": "int64"
+ },
+ "enabled": {
+ "description": "Enabled indicates whether the peer is active. Auto-restrict disables the peer if over the limit.",
+ "type": "boolean"
+ },
+ "expiresAt": {
+ "description": "ExpiresAt defines when this peer should be considered expired and eligible for auto-deletion",
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "ip": {
+ "type": "string"
+ },
+ "publicKey": {
+ "type": "string"
+ },
+ "serverID": {
+ "type": "integer"
+ },
+ "updatedAt": {
+ "type": "string"
+ }
+ }
+ },
+ "main.SMTPSettings": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "fromEmail": {
+ "type": "string"
+ },
+ "fromName": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "password": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "server": {
+ "type": "string"
+ },
+ "useAuth": {
+ "type": "boolean"
+ },
+ "useTLS": {
+ "type": "boolean"
+ },
+ "username": {
+ "type": "string"
+ }
+ }
+ },
+ "main.Server": {
+ "type": "object",
+ "properties": {
+ "createdAt": {
+ "type": "string"
+ },
+ "endpoint": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "peers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ },
+ "publicKey": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ },
+ "webhooks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ }
+ }
+ },
+ "main.Webhook": {
+ "type": "object",
+ "properties": {
+ "createdAt": {
+ "type": "string"
+ },
+ "customBody": {
+ "type": "string"
+ },
+ "customHeaders": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "defaultPayload": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "isEnabled": {
+ "type": "boolean"
+ },
+ "isGlobal": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "serverID": {
+ "type": "integer"
+ },
+ "subscribedActions": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "template": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "verifySSL": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "securityDefinitions": {
+ "ApiKeyAuth": {
+ "type": "apiKey",
+ "name": "wg-rplane-datadunia",
+ "in": "header"
+ },
+ "BearerAuth": {
+ "type": "apiKey",
+ "name": "Authorization",
+ "in": "header"
+ }
+ }
+}`
+
+// SwaggerInfo holds exported Swagger Info so clients can modify it
+var SwaggerInfo = &swag.Spec{
+ Version: "1.0",
+ Host: "localhost:10087",
+ BasePath: "/",
+ Schemes: []string{},
+ Title: "WGRplane API",
+ Description: "WireGuard Control Plane with Dynamic Policy Firewall. REST API for managing WireGuard servers, peers, webhooks, SMTP settings, and real-time stats.",
+ InfoInstanceName: "swagger",
+ SwaggerTemplate: docTemplate,
+ LeftDelim: "{{",
+ RightDelim: "}}",
+}
+
+func init() {
+ swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo)
+}
diff --git a/app/docs/swagger.json b/app/docs/swagger.json
new file mode 100644
index 0000000..5fb2a5e
--- /dev/null
+++ b/app/docs/swagger.json
@@ -0,0 +1,1220 @@
+{
+ "swagger": "2.0",
+ "info": {
+ "description": "WireGuard Control Plane with Dynamic Policy Firewall. REST API for managing WireGuard servers, peers, webhooks, SMTP settings, and real-time stats.",
+ "title": "WGRplane API",
+ "contact": {
+ "name": "API Support"
+ },
+ "version": "1.0"
+ },
+ "host": "localhost:10087",
+ "basePath": "/",
+ "paths": {
+ "/api/peers/{id}": {
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Updates a peer and computes diffs to apply incremental nftables changes.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Update a peer",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Updated peer fields",
+ "name": "peer",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Deletes a peer, cleans up nftables rules, and triggers webhooks.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Delete a peer",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No content"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/peers/{id}/config": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Downloads the WireGuard .conf file for a specific peer.",
+ "produces": [
+ "text/plain"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Get peer config",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "WireGuard configuration file",
+ "schema": {
+ "type": "string"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/peers/{id}/qrcode": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns a QR code PNG image of the peer config for mobile import.",
+ "produces": [
+ "image/png"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Get peer QR code",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Peer ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "QR code PNG image",
+ "schema": {
+ "type": "file"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns a list of all registered WireGuard servers.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "List all servers",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Server"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Creates a new WireGuard server with the provided configuration.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Create a server",
+ "parameters": [
+ {
+ "description": "Server configuration",
+ "name": "server",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns a single WireGuard server by its ID.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Get a server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "put": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Updates fields of an existing WireGuard server.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Update a server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Updated server fields",
+ "name": "server",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.Server"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Deletes a WireGuard server and cascade-removes its peers and webhooks.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "servers"
+ ],
+ "summary": "Delete a server",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No content"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}/peers": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns all peers belonging to a specific WireGuard server.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "List server peers",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Creates a new WireGuard peer. Applies nftables rules in forward mode or triggers webhooks in standalone mode.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "peers"
+ ],
+ "summary": "Create a peer",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Peer configuration",
+ "name": "peer",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}/stats": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns per-server statistics including peer count and webhook count.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "stats"
+ ],
+ "summary": "Get server stats",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": true
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/servers/{id}/webhooks": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns all webhooks configured for a specific WireGuard server.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "webhooks"
+ ],
+ "summary": "List server webhooks",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Creates a new webhook configuration for a WireGuard server.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "webhooks"
+ ],
+ "summary": "Create a webhook",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Server ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Webhook configuration",
+ "name": "webhook",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/settings/smtp": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns the current SMTP configuration for email notifications.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "settings"
+ ],
+ "summary": "Get SMTP settings",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.SMTPSettings"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Saves or updates SMTP configuration for email notifications.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "settings"
+ ],
+ "summary": "Save SMTP settings",
+ "parameters": [
+ {
+ "description": "SMTP configuration",
+ "name": "settings",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/main.SMTPSettings"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/main.SMTPSettings"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/stats": {
+ "get": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Returns global statistics including total servers, peers, and webhooks.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "stats"
+ ],
+ "summary": "Get global stats",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "integer",
+ "format": "int64"
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/webhooks/{id}": {
+ "delete": {
+ "security": [
+ {
+ "ApiKeyAuth": []
+ },
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Deletes a webhook by its ID.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "webhooks"
+ ],
+ "summary": "Delete a webhook",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Webhook ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "description": "No content"
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "type": "object",
+ "additionalProperties": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "definitions": {
+ "main.Peer": {
+ "type": "object",
+ "properties": {
+ "allowAccess": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "allowInternet": {
+ "type": "boolean"
+ },
+ "createdAt": {
+ "type": "string"
+ },
+ "currentDataUsageBytes": {
+ "description": "CurrentDataUsageBytes tracks the amount of data used by this peer (in bytes)",
+ "type": "integer",
+ "format": "int64"
+ },
+ "dataLimitGB": {
+ "description": "DataLimitGB defines the monthly data limit per peer (in GB). 0 means unlimited.",
+ "type": "integer",
+ "format": "int64"
+ },
+ "enabled": {
+ "description": "Enabled indicates whether the peer is active. Auto-restrict disables the peer if over the limit.",
+ "type": "boolean"
+ },
+ "expiresAt": {
+ "description": "ExpiresAt defines when this peer should be considered expired and eligible for auto-deletion",
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "ip": {
+ "type": "string"
+ },
+ "publicKey": {
+ "type": "string"
+ },
+ "serverID": {
+ "type": "integer"
+ },
+ "updatedAt": {
+ "type": "string"
+ }
+ }
+ },
+ "main.SMTPSettings": {
+ "type": "object",
+ "properties": {
+ "enabled": {
+ "type": "boolean"
+ },
+ "fromEmail": {
+ "type": "string"
+ },
+ "fromName": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "password": {
+ "type": "string"
+ },
+ "port": {
+ "type": "integer"
+ },
+ "server": {
+ "type": "string"
+ },
+ "useAuth": {
+ "type": "boolean"
+ },
+ "useTLS": {
+ "type": "boolean"
+ },
+ "username": {
+ "type": "string"
+ }
+ }
+ },
+ "main.Server": {
+ "type": "object",
+ "properties": {
+ "createdAt": {
+ "type": "string"
+ },
+ "endpoint": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "mode": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "peers": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Peer"
+ }
+ },
+ "publicKey": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ },
+ "webhooks": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/main.Webhook"
+ }
+ }
+ }
+ },
+ "main.Webhook": {
+ "type": "object",
+ "properties": {
+ "createdAt": {
+ "type": "string"
+ },
+ "customBody": {
+ "type": "string"
+ },
+ "customHeaders": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "defaultPayload": {
+ "type": "string"
+ },
+ "id": {
+ "type": "integer"
+ },
+ "isEnabled": {
+ "type": "boolean"
+ },
+ "isGlobal": {
+ "type": "boolean"
+ },
+ "name": {
+ "type": "string"
+ },
+ "serverID": {
+ "type": "integer"
+ },
+ "subscribedActions": {
+ "type": "array",
+ "items": {
+ "type": "integer"
+ }
+ },
+ "template": {
+ "type": "string"
+ },
+ "updatedAt": {
+ "type": "string"
+ },
+ "url": {
+ "type": "string"
+ },
+ "verifySSL": {
+ "type": "boolean"
+ }
+ }
+ }
+ },
+ "securityDefinitions": {
+ "ApiKeyAuth": {
+ "type": "apiKey",
+ "name": "wg-rplane-datadunia",
+ "in": "header"
+ },
+ "BearerAuth": {
+ "type": "apiKey",
+ "name": "Authorization",
+ "in": "header"
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/docs/swagger.yaml b/app/docs/swagger.yaml
new file mode 100644
index 0000000..a3264f5
--- /dev/null
+++ b/app/docs/swagger.yaml
@@ -0,0 +1,777 @@
+basePath: /
+definitions:
+ main.Peer:
+ properties:
+ allowAccess:
+ items:
+ type: integer
+ type: array
+ allowInternet:
+ type: boolean
+ createdAt:
+ type: string
+ currentDataUsageBytes:
+ description: CurrentDataUsageBytes tracks the amount of data used by this
+ peer (in bytes)
+ format: int64
+ type: integer
+ dataLimitGB:
+ description: DataLimitGB defines the monthly data limit per peer (in GB).
+ 0 means unlimited.
+ format: int64
+ type: integer
+ enabled:
+ description: Enabled indicates whether the peer is active. Auto-restrict disables
+ the peer if over the limit.
+ type: boolean
+ expiresAt:
+ description: ExpiresAt defines when this peer should be considered expired
+ and eligible for auto-deletion
+ type: string
+ id:
+ type: integer
+ ip:
+ type: string
+ publicKey:
+ type: string
+ serverID:
+ type: integer
+ updatedAt:
+ type: string
+ type: object
+ main.SMTPSettings:
+ properties:
+ enabled:
+ type: boolean
+ fromEmail:
+ type: string
+ fromName:
+ type: string
+ id:
+ type: integer
+ password:
+ type: string
+ port:
+ type: integer
+ server:
+ type: string
+ useAuth:
+ type: boolean
+ useTLS:
+ type: boolean
+ username:
+ type: string
+ type: object
+ main.Server:
+ properties:
+ createdAt:
+ type: string
+ endpoint:
+ type: string
+ id:
+ type: integer
+ mode:
+ type: string
+ name:
+ type: string
+ peers:
+ items:
+ $ref: '#/definitions/main.Peer'
+ type: array
+ publicKey:
+ type: string
+ updatedAt:
+ type: string
+ webhooks:
+ items:
+ $ref: '#/definitions/main.Webhook'
+ type: array
+ type: object
+ main.Webhook:
+ properties:
+ createdAt:
+ type: string
+ customBody:
+ type: string
+ customHeaders:
+ items:
+ type: integer
+ type: array
+ defaultPayload:
+ type: string
+ id:
+ type: integer
+ isEnabled:
+ type: boolean
+ isGlobal:
+ type: boolean
+ name:
+ type: string
+ serverID:
+ type: integer
+ subscribedActions:
+ items:
+ type: integer
+ type: array
+ template:
+ type: string
+ updatedAt:
+ type: string
+ url:
+ type: string
+ verifySSL:
+ type: boolean
+ type: object
+host: localhost:10087
+info:
+ contact:
+ name: API Support
+ description: WireGuard Control Plane with Dynamic Policy Firewall. REST API for
+ managing WireGuard servers, peers, webhooks, SMTP settings, and real-time stats.
+ title: WGRplane API
+ version: "1.0"
+paths:
+ /api/peers/{id}:
+ delete:
+ description: Deletes a peer, cleans up nftables rules, and triggers webhooks.
+ parameters:
+ - description: Peer ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "204":
+ description: No content
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Delete a peer
+ tags:
+ - peers
+ put:
+ consumes:
+ - application/json
+ description: Updates a peer and computes diffs to apply incremental nftables
+ changes.
+ parameters:
+ - description: Peer ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Updated peer fields
+ in: body
+ name: peer
+ required: true
+ schema:
+ $ref: '#/definitions/main.Peer'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/main.Peer'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Update a peer
+ tags:
+ - peers
+ /api/peers/{id}/config:
+ get:
+ description: Downloads the WireGuard .conf file for a specific peer.
+ parameters:
+ - description: Peer ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - text/plain
+ responses:
+ "200":
+ description: WireGuard configuration file
+ schema:
+ type: string
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Get peer config
+ tags:
+ - peers
+ /api/peers/{id}/qrcode:
+ get:
+ description: Returns a QR code PNG image of the peer config for mobile import.
+ parameters:
+ - description: Peer ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - image/png
+ responses:
+ "200":
+ description: QR code PNG image
+ schema:
+ type: file
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Get peer QR code
+ tags:
+ - peers
+ /api/servers:
+ get:
+ consumes:
+ - application/json
+ description: Returns a list of all registered WireGuard servers.
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/main.Server'
+ type: array
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: List all servers
+ tags:
+ - servers
+ post:
+ consumes:
+ - application/json
+ description: Creates a new WireGuard server with the provided configuration.
+ parameters:
+ - description: Server configuration
+ in: body
+ name: server
+ required: true
+ schema:
+ $ref: '#/definitions/main.Server'
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/main.Server'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Create a server
+ tags:
+ - servers
+ /api/servers/{id}:
+ delete:
+ description: Deletes a WireGuard server and cascade-removes its peers and webhooks.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "204":
+ description: No content
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Delete a server
+ tags:
+ - servers
+ get:
+ description: Returns a single WireGuard server by its ID.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/main.Server'
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Get a server
+ tags:
+ - servers
+ put:
+ consumes:
+ - application/json
+ description: Updates fields of an existing WireGuard server.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Updated server fields
+ in: body
+ name: server
+ required: true
+ schema:
+ $ref: '#/definitions/main.Server'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/main.Server'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Update a server
+ tags:
+ - servers
+ /api/servers/{id}/peers:
+ get:
+ description: Returns all peers belonging to a specific WireGuard server.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/main.Peer'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: List server peers
+ tags:
+ - peers
+ post:
+ consumes:
+ - application/json
+ description: Creates a new WireGuard peer. Applies nftables rules in forward
+ mode or triggers webhooks in standalone mode.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Peer configuration
+ in: body
+ name: peer
+ required: true
+ schema:
+ $ref: '#/definitions/main.Peer'
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/main.Peer'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Create a peer
+ tags:
+ - peers
+ /api/servers/{id}/stats:
+ get:
+ description: Returns per-server statistics including peer count and webhook
+ count.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ additionalProperties: true
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Get server stats
+ tags:
+ - stats
+ /api/servers/{id}/webhooks:
+ get:
+ description: Returns all webhooks configured for a specific WireGuard server.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/main.Webhook'
+ type: array
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: List server webhooks
+ tags:
+ - webhooks
+ post:
+ consumes:
+ - application/json
+ description: Creates a new webhook configuration for a WireGuard server.
+ parameters:
+ - description: Server ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Webhook configuration
+ in: body
+ name: webhook
+ required: true
+ schema:
+ $ref: '#/definitions/main.Webhook'
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/main.Webhook'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Create a webhook
+ tags:
+ - webhooks
+ /api/settings/smtp:
+ get:
+ description: Returns the current SMTP configuration for email notifications.
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/main.SMTPSettings'
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Get SMTP settings
+ tags:
+ - settings
+ post:
+ consumes:
+ - application/json
+ description: Saves or updates SMTP configuration for email notifications.
+ parameters:
+ - description: SMTP configuration
+ in: body
+ name: settings
+ required: true
+ schema:
+ $ref: '#/definitions/main.SMTPSettings'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/main.SMTPSettings'
+ "400":
+ description: Bad Request
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Save SMTP settings
+ tags:
+ - settings
+ /api/stats:
+ get:
+ description: Returns global statistics including total servers, peers, and webhooks.
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ additionalProperties:
+ format: int64
+ type: integer
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Get global stats
+ tags:
+ - stats
+ /api/webhooks/{id}:
+ delete:
+ description: Deletes a webhook by its ID.
+ parameters:
+ - description: Webhook ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "204":
+ description: No content
+ "404":
+ description: Not Found
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ "500":
+ description: Internal Server Error
+ schema:
+ additionalProperties:
+ type: string
+ type: object
+ security:
+ - ApiKeyAuth: []
+ - BearerAuth: []
+ summary: Delete a webhook
+ tags:
+ - webhooks
+securityDefinitions:
+ ApiKeyAuth:
+ in: header
+ name: wg-rplane-datadunia
+ type: apiKey
+ BearerAuth:
+ in: header
+ name: Authorization
+ type: apiKey
+swagger: "2.0"
diff --git a/app/frontend/index.html b/app/frontend/index.html
index 096d706..bd07f65 100644
--- a/app/frontend/index.html
+++ b/app/frontend/index.html
@@ -4,7 +4,7 @@
-
frontend
+ WGRplane - WireGuard Control
diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json
index 1abe646..f343c36 100644
--- a/app/frontend/package-lock.json
+++ b/app/frontend/package-lock.json
@@ -11,19 +11,38 @@
"@headlessui/vue": "^1.7.23",
"@vueuse/core": "^14.3.0",
"chart.js": "^4.5.1",
+ "vue": "^3.5.33",
"vue-chartjs": "^5.3.3",
+ "vue-i18n": "^9.14.5",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
},
"devDependencies": {
+ "@tailwindcss/postcss": "^4.2.4",
+ "@vitejs/plugin-vue": "^6.0.6",
+ "autoprefixer": "^10.5.0",
+ "postcss": "^8.5.13",
+ "tailwindcss": "^4.2.4",
"typescript": "~6.0.2",
"vite": "^8.0.10"
}
},
+ "node_modules/@alloc/quick-lru": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+ "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -31,7 +50,6 @@
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=6.9.0"
}
@@ -39,7 +57,6 @@
"node_modules/@babel/parser": {
"version": "7.29.3",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/types": "^7.29.0"
},
@@ -53,7 +70,6 @@
"node_modules/@babel/types": {
"version": "7.29.0",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
@@ -111,10 +127,96 @@
"vue": "^3.2.0"
}
},
+ "node_modules/@intlify/core-base": {
+ "version": "9.14.5",
+ "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.14.5.tgz",
+ "integrity": "sha512-5ah5FqZG4pOoHjkvs8mjtv+gPKYU0zCISaYNjBNNqYiaITxW8ZtVih3GS/oTOqN8d9/mDLyrjD46GBApNxmlsA==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/message-compiler": "9.14.5",
+ "@intlify/shared": "9.14.5"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ }
+ },
+ "node_modules/@intlify/message-compiler": {
+ "version": "9.14.5",
+ "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.14.5.tgz",
+ "integrity": "sha512-IHzgEu61/YIpQV5Pc3aRWScDcnFKWvQA9kigcINcCBXN8mbW+vk9SK+lDxA6STzKQsVJxUPg9ACC52pKKo3SVQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/shared": "9.14.5",
+ "source-map-js": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ }
+ },
+ "node_modules/@intlify/shared": {
+ "version": "9.14.5",
+ "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.14.5.tgz",
+ "integrity": "sha512-9gB+E53BYuAEMhbCAxVgG38EZrk59sxBtv3jSizNL2hEWlgjBjAw1AwpLHtNaeda12pe6W20OGEa0TwuMSRbyQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
"license": "MIT",
- "peer": true
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
@@ -427,6 +529,289 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@tailwindcss/node": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz",
+ "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.19.0",
+ "jiti": "^2.6.1",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.2.4"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz",
+ "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.2.4",
+ "@tailwindcss/oxide-darwin-arm64": "4.2.4",
+ "@tailwindcss/oxide-darwin-x64": "4.2.4",
+ "@tailwindcss/oxide-freebsd-x64": "4.2.4",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.2.4",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.2.4",
+ "@tailwindcss/oxide-linux-x64-musl": "4.2.4",
+ "@tailwindcss/oxide-wasm32-wasi": "4.2.4",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.2.4"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz",
+ "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz",
+ "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz",
+ "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz",
+ "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz",
+ "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz",
+ "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz",
+ "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz",
+ "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz",
+ "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz",
+ "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.8.1",
+ "@emnapi/runtime": "^1.8.1",
+ "@emnapi/wasi-threads": "^1.1.0",
+ "@napi-rs/wasm-runtime": "^1.1.1",
+ "@tybys/wasm-util": "^0.10.1",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz",
+ "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz",
+ "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/postcss": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz",
+ "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@alloc/quick-lru": "^5.2.0",
+ "@tailwindcss/node": "4.2.4",
+ "@tailwindcss/oxide": "4.2.4",
+ "postcss": "^8.5.6",
+ "tailwindcss": "4.2.4"
+ }
+ },
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
@@ -470,10 +855,33 @@
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT"
},
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.6",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
+ "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.13"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue/node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.13",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
+ "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@vue/compiler-core": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/shared": "3.5.33",
@@ -485,7 +893,6 @@
"node_modules/@vue/compiler-dom": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33"
@@ -494,7 +901,6 @@
"node_modules/@vue/compiler-sfc": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.33",
@@ -510,7 +916,6 @@
"node_modules/@vue/compiler-ssr": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33"
@@ -525,7 +930,6 @@
"node_modules/@vue/reactivity": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/shared": "3.5.33"
}
@@ -533,7 +937,6 @@
"node_modules/@vue/runtime-core": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33"
@@ -542,7 +945,6 @@
"node_modules/@vue/runtime-dom": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33",
@@ -553,7 +955,6 @@
"node_modules/@vue/server-renderer": {
"version": "3.5.33",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33"
@@ -564,8 +965,7 @@
},
"node_modules/@vue/shared": {
"version": "3.5.33",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/@vueuse/core": {
"version": "14.3.0",
@@ -605,6 +1005,111 @@
"vue": "^3.5.0"
}
},
+ "node_modules/autoprefixer": {
+ "version": "10.5.0",
+ "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+ "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "browserslist": "^4.28.2",
+ "caniuse-lite": "^1.0.30001787",
+ "fraction.js": "^5.3.4",
+ "picocolors": "^1.1.1",
+ "postcss-value-parser": "^4.2.0"
+ },
+ "bin": {
+ "autoprefixer": "bin/autoprefixer"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ },
+ "peerDependencies": {
+ "postcss": "^8.1.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.25",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz",
+ "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.2",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+ "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.12",
+ "caniuse-lite": "^1.0.30001782",
+ "electron-to-chromium": "^1.5.328",
+ "node-releases": "^2.0.36",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001791",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
+ "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -619,8 +1124,7 @@
},
"node_modules/csstype": {
"version": "3.2.3",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
@@ -630,10 +1134,30 @@
"node": ">=8"
}
},
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.349",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz",
+ "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.21.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz",
+ "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
"node_modules/entities": {
"version": "7.0.1",
"license": "BSD-2-Clause",
- "peer": true,
"engines": {
"node": ">=0.12"
},
@@ -641,10 +1165,19 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/estree-walker": {
"version": "2.0.2",
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/fdir": {
"version": "6.5.0",
@@ -662,6 +1195,20 @@
}
}
},
+ "node_modules/fraction.js": {
+ "version": "5.3.4",
+ "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+ "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/rawify"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -677,6 +1224,23 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/jiti": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
+ "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
"node_modules/lightningcss": {
"version": "1.32.0",
"dev": true,
@@ -949,7 +1513,6 @@
"node_modules/magic-string": {
"version": "0.30.21",
"license": "MIT",
- "peer": true,
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
@@ -970,6 +1533,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
+ "node_modules/node-releases": {
+ "version": "2.0.38",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+ "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"license": "ISC"
@@ -987,6 +1557,8 @@
},
"node_modules/postcss": {
"version": "8.5.13",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
+ "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"funding": [
{
"type": "opencollective",
@@ -1011,6 +1583,13 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postcss-value-parser": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/rolldown": {
"version": "1.0.0-rc.17",
"dev": true,
@@ -1050,6 +1629,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/tailwindcss": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz",
+ "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
"node_modules/tinyglobby": {
"version": "0.2.16",
"dev": true,
@@ -1085,6 +1685,37 @@
"node": ">=14.17"
}
},
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
"node_modules/vite": {
"version": "8.0.10",
"dev": true,
@@ -1163,8 +1794,9 @@
},
"node_modules/vue": {
"version": "3.5.33",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
+ "integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"license": "MIT",
- "peer": true,
"dependencies": {
"@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33",
@@ -1191,6 +1823,27 @@
"vue": "^3.0.0-0 || ^2.7.0"
}
},
+ "node_modules/vue-i18n": {
+ "version": "9.14.5",
+ "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.14.5.tgz",
+ "integrity": "sha512-0jQ9Em3ymWngyiIkj0+c/k7WgaPO+TNzjKSNq9BvBQaKJECqn9cd9fL4tkDhB5G1QBskGl9YxxbDAhgbFtpe2g==",
+ "deprecated": "v9 and v10 no longer supported. please migrate to v11. about maintenance status, see https://vue-i18n.intlify.dev/guide/maintenance.html",
+ "license": "MIT",
+ "dependencies": {
+ "@intlify/core-base": "9.14.5",
+ "@intlify/shared": "9.14.5",
+ "@vue/devtools-api": "^6.5.0"
+ },
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/kazupon"
+ },
+ "peerDependencies": {
+ "vue": "^3.0.0"
+ }
+ },
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
diff --git a/app/frontend/package.json b/app/frontend/package.json
index f4a6bb7..189153a 100644
--- a/app/frontend/package.json
+++ b/app/frontend/package.json
@@ -9,6 +9,11 @@
"preview": "vite preview"
},
"devDependencies": {
+ "@tailwindcss/postcss": "^4.2.4",
+ "@vitejs/plugin-vue": "^6.0.6",
+ "autoprefixer": "^10.5.0",
+ "postcss": "^8.5.13",
+ "tailwindcss": "^4.2.4",
"typescript": "~6.0.2",
"vite": "^8.0.10"
},
@@ -16,7 +21,9 @@
"@headlessui/vue": "^1.7.23",
"@vueuse/core": "^14.3.0",
"chart.js": "^4.5.1",
+ "vue": "^3.5.33",
"vue-chartjs": "^5.3.3",
+ "vue-i18n": "^9.14.5",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
}
diff --git a/app/frontend/postcss.config.js b/app/frontend/postcss.config.js
index 2e7af2b..1c87846 100644
--- a/app/frontend/postcss.config.js
+++ b/app/frontend/postcss.config.js
@@ -1,6 +1,6 @@
export default {
plugins: {
- tailwindcss: {},
+ '@tailwindcss/postcss': {},
autoprefixer: {},
},
}
diff --git a/app/frontend/src/App.vue b/app/frontend/src/App.vue
new file mode 100644
index 0000000..bfcad3b
--- /dev/null
+++ b/app/frontend/src/App.vue
@@ -0,0 +1,161 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/frontend/src/components/ActionCheckboxes.vue b/app/frontend/src/components/ActionCheckboxes.vue
new file mode 100644
index 0000000..d6c00c1
--- /dev/null
+++ b/app/frontend/src/components/ActionCheckboxes.vue
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/app/frontend/src/components/CIDRTagInput.vue b/app/frontend/src/components/CIDRTagInput.vue
new file mode 100644
index 0000000..78747bd
--- /dev/null
+++ b/app/frontend/src/components/CIDRTagInput.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
{{ error }}
+
+
+ {{ cidr }}
+
+
+
+
+
+
+
diff --git a/app/frontend/src/components/CustomBodyEditor.vue b/app/frontend/src/components/CustomBodyEditor.vue
new file mode 100644
index 0000000..2cc19bb
--- /dev/null
+++ b/app/frontend/src/components/CustomBodyEditor.vue
@@ -0,0 +1,43 @@
+
+
+
+
+
{{ error }}
+
+ Use {{event}}, {{timestamp}}, {{peer}} as placeholders for dynamic values.
+
+
+
+
+
diff --git a/app/frontend/src/components/HeaderKeyValue.vue b/app/frontend/src/components/HeaderKeyValue.vue
new file mode 100644
index 0000000..e4f9cd6
--- /dev/null
+++ b/app/frontend/src/components/HeaderKeyValue.vue
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/frontend/src/components/InternetToggle.vue b/app/frontend/src/components/InternetToggle.vue
new file mode 100644
index 0000000..a4c68f5
--- /dev/null
+++ b/app/frontend/src/components/InternetToggle.vue
@@ -0,0 +1,29 @@
+
+
+
+
+
diff --git a/app/frontend/src/components/PeerTable.vue b/app/frontend/src/components/PeerTable.vue
new file mode 100644
index 0000000..8f09a4f
--- /dev/null
+++ b/app/frontend/src/components/PeerTable.vue
@@ -0,0 +1,402 @@
+
+
+
+
Peer Management
+
+ + Add Peer
+
+
+
+
+
+ | Peer Name |
+ Public Key |
+ IP Address |
+ Allow Access (CIDRs) |
+ Allow Internet |
+ Expiry Date |
+ Data Usage |
+ Actions |
+
+
+
+
+ | {{ peer.name }} |
+ {{ peer.publicKey }} |
+ {{ peer.ip }} |
+
+
+ |
+
+
+ |
+
+ {{ peer.expiresAt ? new Date(peer.expiresAt).toLocaleDateString() : 'Never' }}
+ |
+
+ {{ peer.currentDataUsageBytes ? formatBytes(peer.currentDataUsageBytes) : '0 B' }}
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ Add New Peer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Allow Internet
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Add Peer
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Edit Peer
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Allow Internet
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancel
+ Save Changes
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Peer QR Code
+
+
![Peer QR Code]()
+
{{ selectedPeerForQR.name }}
+
+
+ Close
+
+
+
+
+
+
+
+
diff --git a/app/frontend/src/components/TemplateDropdown.vue b/app/frontend/src/components/TemplateDropdown.vue
new file mode 100644
index 0000000..1975738
--- /dev/null
+++ b/app/frontend/src/components/TemplateDropdown.vue
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
diff --git a/app/frontend/src/components/WebhookForm.vue b/app/frontend/src/components/WebhookForm.vue
new file mode 100644
index 0000000..2ee02c4
--- /dev/null
+++ b/app/frontend/src/components/WebhookForm.vue
@@ -0,0 +1,127 @@
+
+
+
+
+
diff --git a/app/frontend/src/components/WebhookList.vue b/app/frontend/src/components/WebhookList.vue
new file mode 100644
index 0000000..8ff89e5
--- /dev/null
+++ b/app/frontend/src/components/WebhookList.vue
@@ -0,0 +1,92 @@
+
+
+
+
Configured Webhooks
+
+
+
+
+ No webhooks configured yet. Click "Add Webhook" to create one.
+
+
+
+
+
+
+
+
{{ webhook.name }}
+
+ Enabled
+
+
+ Disabled
+
+
+ {{ webhook.template }}
+
+
+
{{ webhook.url }}
+
+ Subscribed to: {{ webhook.subscribedActions.join(', ') || 'None' }}
+
+
+
+
+
+
+
+
+
+
diff --git a/app/frontend/src/components/glass/GlassButton.vue b/app/frontend/src/components/glass/GlassButton.vue
index 3ff12ef..6d4fb55 100644
--- a/app/frontend/src/components/glass/GlassButton.vue
+++ b/app/frontend/src/components/glass/GlassButton.vue
@@ -1,8 +1,8 @@
-
diff --git a/app/frontend/src/components/glass/GlassCard.vue b/app/frontend/src/components/glass/GlassCard.vue
index b3cab84..ac08aa6 100644
--- a/app/frontend/src/components/glass/GlassCard.vue
+++ b/app/frontend/src/components/glass/GlassCard.vue
@@ -1,5 +1,5 @@
-
+
diff --git a/app/frontend/src/components/glass/GlassInput.vue b/app/frontend/src/components/glass/GlassInput.vue
index 7fe3803..08aa6b7 100644
--- a/app/frontend/src/components/glass/GlassInput.vue
+++ b/app/frontend/src/components/glass/GlassInput.vue
@@ -1,6 +1,6 @@
-
+
diff --git a/app/frontend/src/components/glass/GlassToggle.vue b/app/frontend/src/components/glass/GlassToggle.vue
index de09663..e11688f 100644
--- a/app/frontend/src/components/glass/GlassToggle.vue
+++ b/app/frontend/src/components/glass/GlassToggle.vue
@@ -1,9 +1,9 @@
-
+
('wgrplane-theme', 'auto')
+
+const isDark = useDark({
+ selector: 'html',
+ attribute: 'class',
+ valueDark: 'dark',
+ valueLight: 'light'
+})
+
+watch(theme, (newTheme) => {
+ if (newTheme === 'auto') {
+ localStorage.removeItem('vueuse-color-scheme')
+ } else {
+ localStorage.setItem('vueuse-color-scheme', newTheme)
+ }
+ if (newTheme === 'dark') {
+ isDark.value = true
+ } else if (newTheme === 'light') {
+ isDark.value = false
+ }
+})
+
+export function useTheme() {
+ return {
+ theme,
+ isDark,
+ toggleTheme: (newTheme: Theme) => {
+ theme.value = newTheme
+ }
+ }
+}
diff --git a/app/frontend/src/composables/useWebSocket.ts b/app/frontend/src/composables/useWebSocket.ts
new file mode 100644
index 0000000..81c77a6
--- /dev/null
+++ b/app/frontend/src/composables/useWebSocket.ts
@@ -0,0 +1,144 @@
+import { ref, onUnmounted, type Ref } from 'vue'
+
+export interface PeerStats {
+ publicKey: string
+ ip: string
+ rxBytes: number
+ txBytes: number
+ lastHandshake: string
+ isOnline: boolean
+}
+
+export interface TrafficDataPoint {
+ timestamp: number
+ rxBytes: number
+ txBytes: number
+}
+
+export interface WebSocketStats {
+ totalPeers: number
+ activePeers: number
+ totalRules: number
+ trafficHistory: TrafficDataPoint[]
+ peers: PeerStats[]
+}
+
+export interface UseWebSocketReturn {
+ isConnected: Ref
+ stats: Ref
+ error: Ref
+ connect: () => void
+ disconnect: () => void
+}
+
+const DEFAULT_STATS: WebSocketStats = {
+ totalPeers: 0,
+ activePeers: 0,
+ totalRules: 0,
+ trafficHistory: [],
+ peers: []
+}
+
+export function useWebSocket(url: string = 'ws://localhost:8080/ws/stats'): UseWebSocketReturn {
+ const isConnected = ref(false)
+ const stats = ref({ ...DEFAULT_STATS })
+ const error = ref(null)
+ let ws: WebSocket | null = null
+ let reconnectTimer: ReturnType | null = null
+ const reconnectDelay = 3000
+ let trafficBuffer: TrafficDataPoint[] = []
+
+ const connect = () => {
+ if (ws && ws.readyState === WebSocket.OPEN) return
+
+ error.value = null
+
+ try {
+ ws = new WebSocket(url)
+
+ ws.onopen = () => {
+ isConnected.value = true
+ error.value = null
+ }
+
+ ws.onmessage = (event: MessageEvent) => {
+ try {
+ const data = JSON.parse(event.data)
+
+ if (data.peers !== undefined) {
+ stats.value.peers = data.peers
+ stats.value.totalPeers = data.peers.length
+ stats.value.activePeers = data.peers.filter((p: PeerStats) => p.isOnline).length
+ }
+
+ if (data.totalRules !== undefined) {
+ stats.value.totalRules = data.totalRules
+ }
+
+ if (data.traffic) {
+ const point: TrafficDataPoint = {
+ timestamp: Date.now(),
+ rxBytes: data.traffic.rxBytes || 0,
+ txBytes: data.traffic.txBytes || 0
+ }
+
+ trafficBuffer.push(point)
+ if (trafficBuffer.length > 60) trafficBuffer.shift()
+
+ stats.value.trafficHistory = [...trafficBuffer]
+ }
+
+ if (data.stats) {
+ stats.value = { ...stats.value, ...data.stats }
+ }
+ } catch (e) {
+ console.error('Failed to parse WebSocket message:', e)
+ }
+ }
+
+ ws.onerror = () => {
+ error.value = 'WebSocket connection error'
+ }
+
+ ws.onclose = () => {
+ isConnected.value = false
+ ws = null
+ scheduleReconnect()
+ }
+ } catch (e) {
+ error.value = `Failed to connect: ${e}`
+ scheduleReconnect()
+ }
+ }
+
+ const scheduleReconnect = () => {
+ if (reconnectTimer) clearTimeout(reconnectTimer)
+ reconnectTimer = setTimeout(() => {
+ connect()
+ }, reconnectDelay)
+ }
+
+ const disconnect = () => {
+ if (reconnectTimer) {
+ clearTimeout(reconnectTimer)
+ reconnectTimer = null
+ }
+ if (ws) {
+ ws.close()
+ ws = null
+ }
+ isConnected.value = false
+ }
+
+ onUnmounted(() => {
+ disconnect()
+ })
+
+ return {
+ isConnected,
+ stats,
+ error,
+ connect,
+ disconnect
+ }
+}
diff --git a/app/frontend/src/i18n/index.ts b/app/frontend/src/i18n/index.ts
new file mode 100644
index 0000000..1333c02
--- /dev/null
+++ b/app/frontend/src/i18n/index.ts
@@ -0,0 +1,15 @@
+import { createI18n } from 'vue-i18n'
+import en from './locales/en.json'
+import id from './locales/id.json'
+import zh from './locales/zh.json'
+
+export const i18n = createI18n({
+ legacy: false,
+ locale: 'en',
+ fallbackLocale: 'en',
+ messages: {
+ en,
+ id,
+ zh
+ }
+})
diff --git a/app/frontend/src/i18n/locales/en.json b/app/frontend/src/i18n/locales/en.json
new file mode 100644
index 0000000..e2e78ce
--- /dev/null
+++ b/app/frontend/src/i18n/locales/en.json
@@ -0,0 +1,29 @@
+{
+ "settings": "Settings",
+ "language": "Language",
+ "theme": "Theme",
+ "dark": "Dark",
+ "light": "Light",
+ "auto": "Auto",
+ "english": "English",
+ "indonesian": "Indonesian",
+ "chinese": "Chinese",
+ "dashboard": "Dashboard",
+ "clients": "Clients",
+ "logs": "Logs",
+ "save": "Save",
+ "cancel": "Cancel",
+ "dashboardSettings": "Dashboard Settings",
+ "totpSetup": "TOTP Setup",
+ "smtpSettings": "SMTP Settings",
+ "enableSMTP": "Enable SMTP",
+ "server": "Server",
+ "port": "Port",
+ "useTLS": "Use TLS",
+ "username": "Username",
+ "password": "Password",
+ "fromEmail": "From Email",
+ "fromName": "From Name",
+ "testEmail": "Test Email",
+ "saveSettings": "Save Settings"
+}
\ No newline at end of file
diff --git a/app/frontend/src/i18n/locales/id.json b/app/frontend/src/i18n/locales/id.json
new file mode 100644
index 0000000..d40d0fd
--- /dev/null
+++ b/app/frontend/src/i18n/locales/id.json
@@ -0,0 +1,29 @@
+{
+ "settings": "Pengaturan",
+ "language": "Bahasa",
+ "theme": "Tema",
+ "dark": "Gelap",
+ "light": "Terang",
+ "auto": "Otomatis",
+ "english": "Inggris",
+ "indonesian": "Indonesia",
+ "chinese": "Mandarin",
+ "dashboard": "Dasbor",
+ "clients": "Klien",
+ "logs": "Log",
+ "save": "Simpan",
+ "cancel": "Batal",
+ "dashboardSettings": "Pengaturan Dasbor",
+ "totpSetup": "Pengaturan TOTP",
+ "smtpSettings": "Pengaturan SMTP",
+ "enableSMTP": "Aktifkan SMTP",
+ "server": "Server",
+ "port": "Port",
+ "useTLS": "Gunakan TLS",
+ "username": "Nama Pengguna",
+ "password": "Kata Sandi",
+ "fromEmail": "Email Pengirim",
+ "fromName": "Nama Pengirim",
+ "testEmail": "Tes Email",
+ "saveSettings": "Simpan Pengaturan"
+}
\ No newline at end of file
diff --git a/app/frontend/src/i18n/locales/zh.json b/app/frontend/src/i18n/locales/zh.json
new file mode 100644
index 0000000..1b290a9
--- /dev/null
+++ b/app/frontend/src/i18n/locales/zh.json
@@ -0,0 +1,29 @@
+{
+ "settings": "设置",
+ "language": "语言",
+ "theme": "主题",
+ "dark": "深色",
+ "light": "浅色",
+ "auto": "自动",
+ "english": "英语",
+ "indonesian": "印尼语",
+ "chinese": "中文",
+ "dashboard": "仪表盘",
+ "clients": "客户端",
+ "logs": "日志",
+ "save": "保存",
+ "cancel": "取消",
+ "dashboardSettings": "仪表盘设置",
+ "totpSetup": "TOTP 设置",
+ "smtpSettings": "SMTP 设置",
+ "enableSMTP": "启用 SMTP",
+ "server": "服务器",
+ "port": "端口",
+ "useTLS": "使用 TLS",
+ "username": "用户名",
+ "password": "密码",
+ "fromEmail": "发件人邮箱",
+ "fromName": "发件人名称",
+ "testEmail": "测试邮件",
+ "saveSettings": "保存设置"
+}
\ No newline at end of file
diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts
index d72bbca..b56fbca 100644
--- a/app/frontend/src/main.ts
+++ b/app/frontend/src/main.ts
@@ -1,60 +1,10 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import router from './router'
+import { i18n } from './i18n'
import './style.css'
-import typescriptLogo from './assets/typescript.svg'
-import viteLogo from './assets/vite.svg'
-import heroImg from './assets/hero.png'
-import { setupCounter } from './counter.ts'
-document.querySelector('#app')!.innerHTML = `
-
-
-
-
Get started
-
Edit src/main.ts and save to test HMR
-
-
-
-
-
-
-
-
-
-
Documentation
-
Your questions, answered
-
-
-
-
-
Connect with us
-
Join the Vite community
-
-
-
-
-
-
-`
-
-setupCounter(document.querySelector('#counter')!)
+const app = createApp(App)
+app.use(router)
+app.use(i18n)
+app.mount('#app')
diff --git a/app/frontend/src/router/index.ts b/app/frontend/src/router/index.ts
new file mode 100644
index 0000000..ed0280e
--- /dev/null
+++ b/app/frontend/src/router/index.ts
@@ -0,0 +1,45 @@
+import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
+
+const routes: RouteRecordRaw[] = [
+ {
+ path: '/',
+ name: 'home',
+ component: () => import('../views/HomeView.vue'),
+ meta: { title: 'Dashboard' }
+ },
+ {
+ path: '/server/:id',
+ name: 'server-detail',
+ component: () => import('../views/ServerDetailView.vue'),
+ meta: { title: 'Server Detail' }
+ },
+ {
+ path: '/server/:id/peers',
+ name: 'peers',
+ component: () => import('../views/PeersView.vue'),
+ meta: { title: 'Peer Management' }
+ },
+ {
+ path: '/server/:id/webhooks',
+ name: 'webhooks',
+ component: () => import('../views/WebhooksView.vue'),
+ meta: { title: 'Webhook Management' }
+ },
+ {
+ path: '/settings',
+ name: 'settings',
+ component: () => import('../views/SettingsView.vue'),
+ meta: { title: 'Settings' }
+ }
+]
+
+const router = createRouter({
+ history: createWebHistory(),
+ routes
+})
+
+router.beforeEach((to) => {
+ document.title = `${to.meta.title || 'WGRplane'} - WGRplane`
+})
+
+export default router
diff --git a/app/frontend/src/shims-vue.d.ts b/app/frontend/src/shims-vue.d.ts
new file mode 100644
index 0000000..2b97bd9
--- /dev/null
+++ b/app/frontend/src/shims-vue.d.ts
@@ -0,0 +1,5 @@
+declare module '*.vue' {
+ import type { DefineComponent } from 'vue'
+ const component: DefineComponent<{}, {}, any>
+ export default component
+}
diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css
index 527d4fb..c8fab9d 100644
--- a/app/frontend/src/style.css
+++ b/app/frontend/src/style.css
@@ -1,296 +1,13 @@
-:root {
- --text: #6b6375;
- --text-h: #08060d;
- --bg: #fff;
- --border: #e5e4e7;
- --code-bg: #f4f3ec;
- --accent: #aa3bff;
- --accent-bg: rgba(170, 59, 255, 0.1);
- --accent-border: rgba(170, 59, 255, 0.5);
- --social-bg: rgba(244, 243, 236, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
-
- --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
- --heading: system-ui, 'Segoe UI', Roboto, sans-serif;
- --mono: ui-monospace, Consolas, monospace;
-
- font: 18px/145% var(--sans);
- letter-spacing: 0.18px;
- color-scheme: light dark;
- color: var(--text);
- background: var(--bg);
- font-synthesis: none;
- text-rendering: optimizeLegibility;
- -webkit-font-smoothing: antialiased;
- -moz-osx-font-smoothing: grayscale;
-
- @media (max-width: 1024px) {
- font-size: 16px;
- }
-}
-
-@media (prefers-color-scheme: dark) {
- :root {
- --text: #9ca3af;
- --text-h: #f3f4f6;
- --bg: #16171d;
- --border: #2e303a;
- --code-bg: #1f2028;
- --accent: #c084fc;
- --accent-bg: rgba(192, 132, 252, 0.15);
- --accent-border: rgba(192, 132, 252, 0.5);
- --social-bg: rgba(47, 48, 58, 0.5);
- --shadow:
- rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
- }
-
- #social .button-icon {
- filter: invert(1) brightness(2);
- }
-}
+@tailwind base;
+@tailwind components;
+@tailwind utilities;
body {
margin: 0;
-}
-
-h1,
-h2 {
- font-family: var(--heading);
- font-weight: 500;
- color: var(--text-h);
-}
-
-h1 {
- font-size: 56px;
- letter-spacing: -1.68px;
- margin: 32px 0;
- @media (max-width: 1024px) {
- font-size: 36px;
- margin: 20px 0;
- }
-}
-h2 {
- font-size: 24px;
- line-height: 118%;
- letter-spacing: -0.24px;
- margin: 0 0 8px;
- @media (max-width: 1024px) {
- font-size: 20px;
- }
-}
-p {
- margin: 0;
-}
-
-code,
-.counter {
- font-family: var(--mono);
- display: inline-flex;
- border-radius: 4px;
- color: var(--text-h);
-}
-
-code {
- font-size: 15px;
- line-height: 135%;
- padding: 4px 8px;
- background: var(--code-bg);
-}
-
-.counter {
- font-size: 16px;
- padding: 5px 10px;
- border-radius: 5px;
- color: var(--accent);
- background: var(--accent-bg);
- border: 2px solid transparent;
- transition: border-color 0.3s;
- margin-bottom: 24px;
-
- &:hover {
- border-color: var(--accent-border);
- }
- &:focus-visible {
- outline: 2px solid var(--accent);
- outline-offset: 2px;
- }
-}
-
-.hero {
- position: relative;
-
- .base,
- .framework,
- .vite {
- inset-inline: 0;
- margin: 0 auto;
- }
-
- .base {
- width: 170px;
- position: relative;
- z-index: 0;
- }
-
- .framework,
- .vite {
- position: absolute;
- }
-
- .framework {
- z-index: 1;
- top: 34px;
- height: 28px;
- transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
- scale(1.4);
- }
-
- .vite {
- z-index: 0;
- top: 107px;
- height: 26px;
- width: auto;
- transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
- scale(0.8);
- }
+ background: #0f172a;
+ color: #fff;
}
#app {
- width: 1126px;
- max-width: 100%;
- margin: 0 auto;
- text-align: center;
- border-inline: 1px solid var(--border);
- min-height: 100svh;
- display: flex;
- flex-direction: column;
- box-sizing: border-box;
-}
-
-#center {
- display: flex;
- flex-direction: column;
- gap: 25px;
- place-content: center;
- place-items: center;
- flex-grow: 1;
-
- @media (max-width: 1024px) {
- padding: 32px 20px 24px;
- gap: 18px;
- }
-}
-
-#next-steps {
- display: flex;
- border-top: 1px solid var(--border);
- text-align: left;
-
- & > div {
- flex: 1 1 0;
- padding: 32px;
- @media (max-width: 1024px) {
- padding: 24px 20px;
- }
- }
-
- .icon {
- margin-bottom: 16px;
- width: 22px;
- height: 22px;
- }
-
- @media (max-width: 1024px) {
- flex-direction: column;
- text-align: center;
- }
-}
-
-#docs {
- border-right: 1px solid var(--border);
-
- @media (max-width: 1024px) {
- border-right: none;
- border-bottom: 1px solid var(--border);
- }
-}
-
-#next-steps ul {
- list-style: none;
- padding: 0;
- display: flex;
- gap: 8px;
- margin: 32px 0 0;
-
- .logo {
- height: 18px;
- }
-
- a {
- color: var(--text-h);
- font-size: 16px;
- border-radius: 6px;
- background: var(--social-bg);
- display: flex;
- padding: 6px 12px;
- align-items: center;
- gap: 8px;
- text-decoration: none;
- transition: box-shadow 0.3s;
-
- &:hover {
- box-shadow: var(--shadow);
- }
- .button-icon {
- height: 18px;
- width: 18px;
- }
- }
-
- @media (max-width: 1024px) {
- margin-top: 20px;
- flex-wrap: wrap;
- justify-content: center;
-
- li {
- flex: 1 1 calc(50% - 8px);
- }
-
- a {
- width: 100%;
- justify-content: center;
- box-sizing: border-box;
- }
- }
-}
-
-#spacer {
- height: 88px;
- border-top: 1px solid var(--border);
- @media (max-width: 1024px) {
- height: 48px;
- }
-}
-
-.ticks {
- position: relative;
- width: 100%;
-
- &::before,
- &::after {
- content: '';
- position: absolute;
- top: -4.5px;
- border: 5px solid transparent;
- }
-
- &::before {
- left: 0;
- border-left-color: var(--border);
- }
- &::after {
- right: 0;
- border-right-color: var(--border);
- }
+ min-height: 100vh;
}
diff --git a/app/frontend/src/types/webhook.ts b/app/frontend/src/types/webhook.ts
new file mode 100644
index 0000000..80dc8ff
--- /dev/null
+++ b/app/frontend/src/types/webhook.ts
@@ -0,0 +1,47 @@
+export interface WebhookHeader {
+ key: string
+ value: string
+}
+
+export interface Webhook {
+ id: string
+ name: string
+ url: string
+ template: string
+ customBody: string
+ headers: WebhookHeader[]
+ subscribedActions: string[]
+ verifySSL: boolean
+ enabled: boolean
+ createdAt?: string
+ updatedAt?: string
+}
+
+export const mockWebhooks: Webhook[] = [
+ {
+ id: '1',
+ name: 'Slack Alerts',
+ url: 'https://hooks.slack.com/services/xxx/yyy/zzz',
+ template: 'slack',
+ customBody: '',
+ headers: [{ key: 'Content-Type', value: 'application/json' }],
+ subscribedActions: ['peer.connected', 'peer.disconnected'],
+ verifySSL: true,
+ enabled: true,
+ createdAt: '2026-04-15T08:30:00Z',
+ updatedAt: '2026-05-01T14:20:00Z'
+ },
+ {
+ id: '2',
+ name: 'Discord Notifications',
+ url: 'https://discord.com/api/webhooks/xxx/yyy',
+ template: 'discord',
+ customBody: '',
+ headers: [{ key: 'Content-Type', value: 'application/json' }],
+ subscribedActions: ['policy.updated', 'server.started'],
+ verifySSL: true,
+ enabled: false,
+ createdAt: '2026-04-20T10:15:00Z',
+ updatedAt: '2026-04-28T09:45:00Z'
+ }
+]
diff --git a/app/frontend/src/views/HomeView.vue b/app/frontend/src/views/HomeView.vue
new file mode 100644
index 0000000..e0d8196
--- /dev/null
+++ b/app/frontend/src/views/HomeView.vue
@@ -0,0 +1,188 @@
+
+
+
+
Dashboard
+
+
+
+ {{ isConnected ? 'Live' : 'Disconnected' }}
+
+
+
+
+
+
+
+ {{ stats.totalPeers }}
+
+
+
+
+ {{ stats.activePeers }}
+
+
+
+
+ {{ stats.totalRules }}
+
+
+
+
+ Traffic Overview
+
+
+
+ Waiting for data...
+
+
+
+
+
+ Peer Status
+
+
+
+
+ RX: {{ formatBytes(peer.rxBytes) }} | TX: {{ formatBytes(peer.txBytes) }}
+
+
+
+ No peer data available
+
+
+
+ {{ error }}
+
+
+
+
+
diff --git a/app/frontend/src/views/PeersView.vue b/app/frontend/src/views/PeersView.vue
new file mode 100644
index 0000000..15f5da2
--- /dev/null
+++ b/app/frontend/src/views/PeersView.vue
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
Peer Management
+
+
Server ID: {{ $route.params.id }}
+
+
+
+
+
+
diff --git a/app/frontend/src/views/ServerDetailView.vue b/app/frontend/src/views/ServerDetailView.vue
new file mode 100644
index 0000000..448c224
--- /dev/null
+++ b/app/frontend/src/views/ServerDetailView.vue
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
Server Detail
+
+
Server ID: {{ $route.params.id }}
+
+
+ Server Information
+ Server details will be displayed here.
+
+
+
+ Peer Table
+ Peer list for this server will be displayed here.
+
+
+
+
+
diff --git a/app/frontend/src/views/SettingsView.vue b/app/frontend/src/views/SettingsView.vue
new file mode 100644
index 0000000..e87a1db
--- /dev/null
+++ b/app/frontend/src/views/SettingsView.vue
@@ -0,0 +1,160 @@
+
+
+
{{ t('settings') }}
+
+
+ {{ t('dashboardSettings') }}
+ Dashboard configuration options will be displayed here.
+
+
+
+
+ {{ t('language') }}
+
+
+ {{ t('english') }}
+
+
+ {{ t('indonesian') }}
+
+
+ {{ t('chinese') }}
+
+
+
+
+
+
+ {{ t('theme') }}
+
+
+ {{ t('dark') }}
+
+
+ {{ t('light') }}
+
+
+ {{ t('auto') }}
+
+
+
+
+
+ {{ t('totpSetup') }}
+ Two-factor authentication setup will be displayed here.
+
+
+
+ {{ t('smtpSettings') }}
+
+
+ {{ t('enableSMTP') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('useTLS') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ t('testEmail') }}
+ {{ t('saveSettings') }}
+
+
+
+
+
+
diff --git a/app/frontend/src/views/WebhooksView.vue b/app/frontend/src/views/WebhooksView.vue
new file mode 100644
index 0000000..e656cea
--- /dev/null
+++ b/app/frontend/src/views/WebhooksView.vue
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
Webhook Management
+
+
Server ID: {{ $route.params.id }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ editingWebhook ? 'Edit Webhook' : 'Add New Webhook' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/frontend/tailwind.config.js b/app/frontend/tailwind.config.js
index 7c24b4e..f10e487 100644
--- a/app/frontend/tailwind.config.js
+++ b/app/frontend/tailwind.config.js
@@ -3,6 +3,7 @@ export default {
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
+ darkMode: 'class',
theme: {
extend: {
backdropBlur: {
diff --git a/app/frontend/vite.config.ts b/app/frontend/vite.config.ts
new file mode 100644
index 0000000..c40aa3c
--- /dev/null
+++ b/app/frontend/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+ plugins: [vue()],
+})
diff --git a/app/go.mod b/app/go.mod
deleted file mode 100644
index df53638..0000000
--- a/app/go.mod
+++ /dev/null
@@ -1,15 +0,0 @@
-module git.datadunia.com/hainzero/WGRplane
-
-go 1.20
-
-require (
- github.com/gorilla/mux v1.8.1 // indirect
- github.com/jinzhu/inflection v1.0.0 // indirect
- github.com/jinzhu/now v1.1.5 // indirect
- github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect
- github.com/mattn/go-sqlite3 v1.14.22 // indirect
- github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
- golang.org/x/text v0.20.0 // indirect
- gorm.io/driver/sqlite v1.6.0 // indirect
- gorm.io/gorm v1.31.1 // indirect
-)
diff --git a/app/go.sum b/app/go.sum
index a11d184..d9b3a85 100644
--- a/app/go.sum
+++ b/app/go.sum
@@ -1,18 +1,53 @@
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
+github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
+github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
+github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
+github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
+github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
+github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
+github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
+github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
+github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
-github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
-github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
-github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
+github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
+github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
+github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
+github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
+github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
+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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
-gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
-gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
+modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
+modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
+modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
+modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
+modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
+modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
+modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
diff --git a/app/handlers.go b/app/handlers.go
new file mode 100644
index 0000000..7c3ab50
--- /dev/null
+++ b/app/handlers.go
@@ -0,0 +1,785 @@
+package main
+
+import (
+ "encoding/json"
+ "net/http"
+ "fmt"
+
+ "github.com/gorilla/mux"
+ "gorm.io/gorm"
+ qrcode "github.com/skip2/go-qrcode"
+)
+
+// Helper wrappers
+func respondJSON(w http.ResponseWriter, status int, payload interface{}) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ _ = json.NewEncoder(w).Encode(payload)
+}
+
+func respondError(w http.ResponseWriter, status int, message string) {
+ respondJSON(w, status, map[string]string{"error": message})
+}
+
+// ListServers lists all registered WireGuard servers.
+// @Summary List all servers
+// @Description Returns a list of all registered WireGuard servers.
+// @Tags servers
+// @Accept json
+// @Produce json
+// @Success 200 {array} Server
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers [get]
+func getServers(w http.ResponseWriter, r *http.Request) {
+ var servers []Server
+ if err := db.Find(&servers).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, servers)
+}
+
+// CreateServer creates a new WireGuard server entry.
+// @Summary Create a server
+// @Description Creates a new WireGuard server with the provided configuration.
+// @Tags servers
+// @Accept json
+// @Produce json
+// @Param server body Server true "Server configuration"
+// @Success 201 {object} Server
+// @Failure 400 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers [post]
+func createServer(w http.ResponseWriter, r *http.Request) {
+ var s Server
+ if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+ // Input validation
+ if !ValidatePublicKey(s.PublicKey) {
+ respondError(w, http.StatusBadRequest, "invalid public key")
+ return
+ }
+ if err := db.Create(&s).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusCreated, s)
+}
+
+// GetServer retrieves a single WireGuard server by ID.
+// @Summary Get a server
+// @Description Returns a single WireGuard server by its ID.
+// @Tags servers
+// @Produce json
+// @Param id path string true "Server ID"
+// @Success 200 {object} Server
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id} [get]
+func getServer(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ respondJSON(w, http.StatusOK, s)
+}
+
+// UpdateServer updates an existing WireGuard server.
+// @Summary Update a server
+// @Description Updates fields of an existing WireGuard server.
+// @Tags servers
+// @Accept json
+// @Produce json
+// @Param id path string true "Server ID"
+// @Param server body Server true "Updated server fields"
+// @Success 200 {object} Server
+// @Failure 400 {object} map[string]string
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id} [put]
+func updateServer(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var updates Server
+ if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+ // Input validation for updated fields
+ if updates.PublicKey != "" && !ValidatePublicKey(updates.PublicKey) {
+ respondError(w, http.StatusBadRequest, "invalid public key")
+ return
+ }
+ // Apply updates using fields defined in models.go
+ s.Name = updates.Name
+ s.Mode = updates.Mode
+ s.PublicKey = updates.PublicKey
+ s.Endpoint = updates.Endpoint
+ if err := db.Save(&s).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, s)
+}
+
+// DeleteServer removes a WireGuard server and cascades to its peers and webhooks.
+// @Summary Delete a server
+// @Description Deletes a WireGuard server and cascade-removes its peers and webhooks.
+// @Tags servers
+// @Produce json
+// @Param id path string true "Server ID"
+// @Success 204 "No content"
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id} [delete]
+func deleteServer(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ if err := db.Delete(&s).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusNoContent, nil)
+}
+
+// ListServerPeers lists all peers for a given WireGuard server.
+// @Summary List server peers
+// @Description Returns all peers belonging to a specific WireGuard server.
+// @Tags peers
+// @Produce json
+// @Param id path string true "Server ID"
+// @Success 200 {array} Peer
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id}/peers [get]
+func getServerPeers(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ // Ensure server exists
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var peers []Peer
+ if err := db.Where("server_id = ?", id).Find(&peers).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, peers)
+}
+
+// CreateServerPeer creates a new peer for a WireGuard server.
+// @Summary Create a peer
+// @Description Creates a new WireGuard peer. Applies nftables rules in forward mode or triggers webhooks in standalone mode.
+// @Tags peers
+// @Accept json
+// @Produce json
+// @Param id path string true "Server ID"
+// @Param peer body Peer true "Peer configuration"
+// @Success 201 {object} Peer
+// @Failure 400 {object} map[string]string
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id}/peers [post]
+func createServerPeer(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ // ensure server exists
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var p Peer
+ if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+ // Input validation for peer
+ if !ValidatePublicKey(p.PublicKey) {
+ respondError(w, http.StatusBadRequest, "invalid public key")
+ return
+ }
+ if !ValidateIP(p.IP) {
+ respondError(w, http.StatusBadRequest, "invalid ip address for peer IP")
+ return
+ }
+ // Optional: validate AllowAccess CIDRs if provided
+ if len(p.AllowAccess) > 0 {
+ var targets []string
+ if err := json.Unmarshal(p.AllowAccess, &targets); err == nil {
+ for _, t := range targets {
+ if !ValidateCIDR(t) {
+ respondError(w, http.StatusBadRequest, "invalid CIDR in AllowAccess")
+ return
+ }
+ }
+ }
+ }
+ p.ServerID = s.ID
+ if err := db.Create(&p).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ // Trigger mode-specific actions after creation
+ policyChanges := []string{"peer_created"}
+ if s.Mode == "forward" {
+ // Apply nftables rules for the new peer
+ if len(p.AllowAccess) > 0 {
+ var targets []string
+ if err := json.Unmarshal(p.AllowAccess, &targets); err == nil {
+ for _, t := range targets {
+ _ = AddAccessRule(p.IP, t)
+ }
+ }
+ }
+ _ = SetInternetAccess(p.IP, p.AllowInternet)
+ // Notify policy change
+ _ = TriggerWebhook("policy_changed", &s, &p, "created", policyChanges)
+ } else if s.Mode == "standalone" {
+ // Notify remote via webhooks
+ _ = TriggerWebhook("peer_created", &s, &p, "created", []string{})
+ // Also notify policy change
+ _ = TriggerWebhook("policy_changed", &s, &p, "created", policyChanges)
+ }
+ respondJSON(w, http.StatusCreated, p)
+}
+
+// UpdatePeer updates an existing WireGuard peer.
+// @Summary Update a peer
+// @Description Updates a peer and computes diffs to apply incremental nftables changes.
+// @Tags peers
+// @Accept json
+// @Produce json
+// @Param id path string true "Peer ID"
+// @Param peer body Peer true "Updated peer fields"
+// @Success 200 {object} Peer
+// @Failure 400 {object} map[string]string
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/peers/{id} [put]
+func updatePeer(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var p Peer
+ if err := db.First(&p, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "peer not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var updates Peer
+ if err := json.NewDecoder(r.Body).Decode(&updates); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+ // Preserve the original peer for diff computation
+ oldP := p
+ // Validate updated fields
+ if updates.PublicKey != "" && !ValidatePublicKey(updates.PublicKey) {
+ respondError(w, http.StatusBadRequest, "invalid public key")
+ return
+ }
+ if updates.IP != "" && !ValidateIP(updates.IP) {
+ respondError(w, http.StatusBadRequest, "invalid ip address for peer IP")
+ return
+ }
+ if len(updates.AllowAccess) > 0 {
+ var targets []string
+ if err := json.Unmarshal(updates.AllowAccess, &targets); err == nil {
+ for _, t := range targets {
+ if !ValidateCIDR(t) {
+ respondError(w, http.StatusBadRequest, "invalid CIDR in AllowAccess")
+ return
+ }
+ }
+ }
+ }
+ // Build a list of policy-related changes for notification purposes
+ changes := []string{}
+ if updates.PublicKey != "" && updates.PublicKey != p.PublicKey {
+ changes = append(changes, "PublicKey updated")
+ }
+ if updates.IP != "" && updates.IP != p.IP {
+ changes = append(changes, "IP updated")
+ }
+ if len(updates.AllowAccess) > 0 && string(updates.AllowAccess) != string(p.AllowAccess) {
+ changes = append(changes, "AllowAccess updated")
+ }
+ if updates.AllowInternet != p.AllowInternet {
+ changes = append(changes, "AllowInternet updated")
+ }
+ // Apply updates based on the Peer model fields
+ p.PublicKey = updates.PublicKey
+ p.IP = updates.IP
+ p.AllowAccess = updates.AllowAccess
+ p.AllowInternet = updates.AllowInternet
+ if err := db.Save(&p).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ // If policy-related fields changed, apply mode-specific actions
+ if len(changes) > 0 {
+ var server Server
+ if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil {
+ if server.Mode == "forward" {
+ // nftables-based updates: diff AllowAccess and IP/internet changes
+ // Compute targets diffs between old and new
+ var oldTargets []string
+ if len(oldP.AllowAccess) > 0 {
+ _ = json.Unmarshal(oldP.AllowAccess, &oldTargets)
+ }
+ var newTargets []string
+ if len(p.AllowAccess) > 0 {
+ _ = json.Unmarshal(p.AllowAccess, &newTargets)
+ }
+ // Determine additions/removals
+ added := []string{}
+ for _, t := range newTargets {
+ found := false
+ for _, o := range oldTargets {
+ if o == t { found = true; break }
+ }
+ if !found { added = append(added, t) }
+ }
+ removed := []string{}
+ for _, o := range oldTargets {
+ found := false
+ for _, t := range newTargets {
+ if o == t { found = true; break }
+ }
+ if !found { removed = append(removed, o) }
+ }
+ // Apply removals first for safety
+ fromIP := oldP.IP
+ if oldP.IP == "" {
+ fromIP = p.IP
+ }
+ for _, t := range removed {
+ _ = RemoveAccessRule(fromIP, t)
+ }
+ // Apply additions for the current IP
+ toIP := p.IP
+ for _, t := range added {
+ _ = AddAccessRule(toIP, t)
+ }
+ // Internet access flag
+ _ = SetInternetAccess(p.IP, p.AllowInternet)
+ // Notify policy change
+ _ = TriggerWebhook("policy_changed", &server, &p, "updated", changes)
+ } else if server.Mode == "standalone" {
+ _ = TriggerWebhook("peer_updated", &server, &p, "updated", changes)
+ // Also notify policy change
+ _ = TriggerWebhook("policy_changed", &server, &p, "updated", changes)
+ }
+ }
+ }
+ respondJSON(w, http.StatusOK, p)
+}
+
+// DeletePeer removes a WireGuard peer and cleans up nftables rules.
+// @Summary Delete a peer
+// @Description Deletes a peer, cleans up nftables rules, and triggers webhooks.
+// @Tags peers
+// @Produce json
+// @Param id path string true "Peer ID"
+// @Success 204 "No content"
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/peers/{id} [delete]
+func deletePeer(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var p Peer
+ if err := db.First(&p, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "peer not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ // If in forward mode, remove any NFTables rules for this peer before deletion
+ var server Server
+ if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil {
+ if server.Mode == "forward" {
+ var targets []string
+ if len(p.AllowAccess) > 0 {
+ _ = json.Unmarshal(p.AllowAccess, &targets)
+ }
+ for _, t := range targets {
+ _ = RemoveAccessRule(p.IP, t)
+ }
+ _ = SetInternetAccess(p.IP, false)
+ // Notify policy change
+ _ = TriggerWebhook("policy_changed", &server, &p, "deleted", []string{"peer_deleted"})
+ }
+ }
+ if err := db.Delete(&p).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ // Notify external systems about the deletion according to server mode
+ if err := db.First(&server, "id = ?", p.ServerID).Error; err == nil {
+ if server.Mode == "standalone" {
+ _ = TriggerWebhook("peer_deleted", &server, &p, "deleted", []string{})
+ // Also notify policy change
+ _ = TriggerWebhook("policy_changed", &server, &p, "deleted", []string{"peer_deleted"})
+ }
+ }
+ respondJSON(w, http.StatusNoContent, nil)
+}
+
+// GetSMTPSettings retrieves the current SMTP configuration.
+// @Summary Get SMTP settings
+// @Description Returns the current SMTP configuration for email notifications.
+// @Tags settings
+// @Produce json
+// @Success 200 {object} SMTPSettings
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/settings/smtp [get]
+func getSMTPSettings(w http.ResponseWriter, r *http.Request) {
+ settings, err := GetSMTPSettings()
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, settings)
+}
+
+// SetSMTPSettings saves SMTP configuration for email notifications.
+// @Summary Save SMTP settings
+// @Description Saves or updates SMTP configuration for email notifications.
+// @Tags settings
+// @Accept json
+// @Produce json
+// @Param settings body SMTPSettings true "SMTP configuration"
+// @Success 200 {object} SMTPSettings
+// @Failure 400 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/settings/smtp [post]
+func setSMTPSettings(w http.ResponseWriter, r *http.Request) {
+ var s SMTPSettings
+ if err := json.NewDecoder(r.Body).Decode(&s); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+ if err := SaveSMTPSettings(s); err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, s)
+}
+
+// ListServerWebhooks lists all webhooks for a WireGuard server.
+// @Summary List server webhooks
+// @Description Returns all webhooks configured for a specific WireGuard server.
+// @Tags webhooks
+// @Produce json
+// @Param id path string true "Server ID"
+// @Success 200 {array} Webhook
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id}/webhooks [get]
+func getServerWebhooks(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var webhooks []Webhook
+ if err := db.Where("server_id = ?", id).Find(&webhooks).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusOK, webhooks)
+}
+
+// CreateServerWebhook creates a new webhook for a WireGuard server.
+// @Summary Create a webhook
+// @Description Creates a new webhook configuration for a WireGuard server.
+// @Tags webhooks
+// @Accept json
+// @Produce json
+// @Param id path string true "Server ID"
+// @Param webhook body Webhook true "Webhook configuration"
+// @Success 201 {object} Webhook
+// @Failure 400 {object} map[string]string
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id}/webhooks [post]
+func createServerWebhook(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var wb Webhook
+ if err := json.NewDecoder(r.Body).Decode(&wb); err != nil {
+ respondError(w, http.StatusBadRequest, "invalid request payload")
+ return
+ }
+ wb.ServerID = s.ID
+ if err := db.Create(&wb).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusCreated, wb)
+}
+
+// DeleteWebhook removes a webhook configuration.
+// @Summary Delete a webhook
+// @Description Deletes a webhook by its ID.
+// @Tags webhooks
+// @Produce json
+// @Param id path string true "Webhook ID"
+// @Success 204 "No content"
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/webhooks/{id} [delete]
+func deleteWebhook(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var wb Webhook
+ if err := db.First(&wb, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "webhook not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ if err := db.Delete(&wb).Error; err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ respondJSON(w, http.StatusNoContent, nil)
+}
+
+// GetPeerConfig downloads the WireGuard configuration file for a peer.
+// @Summary Get peer config
+// @Description Downloads the WireGuard .conf file for a specific peer.
+// @Tags peers
+// @Produce plain
+// @Param id path string true "Peer ID"
+// @Success 200 {string} string "WireGuard configuration file"
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/peers/{id}/config [get]
+func getPeerConfig(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var p Peer
+ if err := db.First(&p, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "peer not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var s Server
+ if err := db.First(&s, "id = ?", p.ServerID).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found for peer")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ conf, err := GeneratePeerConfig(p, s)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain")
+ w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=peer-%d.conf", p.ID))
+ w.Write(conf)
+}
+
+// GetPeerQRCode returns a QR code PNG image for the peer configuration.
+// @Summary Get peer QR code
+// @Description Returns a QR code PNG image of the peer config for mobile import.
+// @Tags peers
+// @Produce png
+// @Param id path string true "Peer ID"
+// @Success 200 {file} binary "QR code PNG image"
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/peers/{id}/qrcode [get]
+func getPeerQRCode(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var p Peer
+ if err := db.First(&p, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "peer not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var s Server
+ if err := db.First(&s, "id = ?", p.ServerID).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found for peer")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ conf, err := GeneratePeerConfig(p, s)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ png, err := qrcode.Encode(string(conf), qrcode.Medium, 256)
+ if err != nil {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ w.Header().Set("Content-Type", "image/png")
+ w.Write(png)
+}
+
+// GetStats returns global statistics for servers, peers, and webhooks.
+// @Summary Get global stats
+// @Description Returns global statistics including total servers, peers, and webhooks.
+// @Tags stats
+// @Produce json
+// @Success 200 {object} map[string]int64
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/stats [get]
+func getStats(w http.ResponseWriter, r *http.Request) {
+ var serverCount int64
+ var peerCount int64
+ var webhookCount int64
+ db.Model(&Server{}).Count(&serverCount)
+ db.Model(&Peer{}).Count(&peerCount)
+ db.Model(&Webhook{}).Count(&webhookCount)
+ resp := map[string]interface{}{
+ "servers": serverCount,
+ "peers": peerCount,
+ "webhooks": webhookCount,
+ }
+ respondJSON(w, http.StatusOK, resp)
+}
+
+// GetServerStats returns per-server statistics.
+// @Summary Get server stats
+// @Description Returns per-server statistics including peer count and webhook count.
+// @Tags stats
+// @Produce json
+// @Param id path string true "Server ID"
+// @Success 200 {object} map[string]interface{}
+// @Failure 404 {object} map[string]string
+// @Failure 500 {object} map[string]string
+// @Security ApiKeyAuth
+// @Security BearerAuth
+// @Router /api/servers/{id}/stats [get]
+func getServerStats(w http.ResponseWriter, r *http.Request) {
+ vars := mux.Vars(r)
+ id := vars["id"]
+ var s Server
+ if err := db.First(&s, "id = ?", id).Error; err != nil {
+ if err == gorm.ErrRecordNotFound {
+ respondError(w, http.StatusNotFound, "server not found")
+ } else {
+ respondError(w, http.StatusInternalServerError, err.Error())
+ }
+ return
+ }
+ var peers []Peer
+ var webhooks []Webhook
+ db.Where("server_id = ?", id).Find(&peers)
+ db.Where("server_id = ?", id).Find(&webhooks)
+ resp := map[string]interface{}{
+ "server": s,
+ "peers": len(peers),
+ "webhooks": len(webhooks),
+ }
+ respondJSON(w, http.StatusOK, resp)
+}
diff --git a/app/i18n.go b/app/i18n.go
new file mode 100644
index 0000000..572bcee
--- /dev/null
+++ b/app/i18n.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "path/filepath"
+ "github.com/nicksnyder/go-i18n/v2/i18n"
+ "golang.org/x/text/language"
+)
+
+var bundle *i18n.Bundle
+
+func init() {
+ if err := initI18nBundle(); err != nil {
+ // Fail fast during startup in tests/builds
+ log.Fatalf("failed to initialize i18n bundle: %v", err)
+ }
+}
+
+func initI18nBundle() error {
+ // Initialize the i18n bundle with English as default
+ bundle = i18n.NewBundle(language.English)
+ bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
+
+ // Load translation files from app/ directory
+ files := []string{
+ filepath.Join("app", "active.en.json"),
+ filepath.Join("app", "active.id.json"),
+ filepath.Join("app", "active.zh.json"),
+ }
+ for _, f := range files {
+ if _, err := bundle.LoadMessageFile(f); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// Translate returns the translated string for a given locale and message ID.
+func Translate(locale, messageID string) string {
+ loc := i18n.NewLocalizer(bundle, locale)
+ s, err := loc.Localize(&i18n.LocalizeConfig{MessageID: messageID})
+ if err != nil || s == "" {
+ // Fallback to the messageID if translation is missing
+ return messageID
+ }
+ return s
+}
diff --git a/app/plan.md b/app/plan.md
index d7c1761..e263a00 100644
--- a/app/plan.md
+++ b/app/plan.md
@@ -82,21 +82,21 @@ iptables / ipset rules
### Phase A: Submodule Initialization
-- [ ] **Task A1**: Initialize WGRplane submodule at `/app`
+- [x] **Task A1**: Initialize WGRplane submodule at `/app`
- File: `/app` (new submodule directory)
- Command: `git submodule add https://git.datadunia.com/hainzero/WGRplane.git app`
- Followed by: `git submodule update --init --recursive`
- QA: `git submodule status` shows `app` with commit hash, no errors
- QA: `/app` directory exists with WGRplane files (Python/Flask backend, Vue.js frontend)
-- [ ] **Task A2**: Verify WGRplane structure and dependencies
+- [x] **Task A2**: Verify WGRplane structure and dependencies
- File: `/app` (submodule contents)
- Inspect: `ls /app` — should contain Python backend, Vue.js frontend, requirements.txt
- Verify WGDashboard-equivalent structure: `app.py` or similar Flask entry point
- QA: WGRplane files present, Python/Flask + Vue.js stack confirmed
- QA: `cat /app/requirements.txt` shows Flask, SQLite, other dependencies
-- [ ] **Task A3**: Create Go module for `wg-engine-api` in `/app`
+- [x] **Task A3**: Create Go module for `wg-engine-api` in `/app`
- File: `/app/wg-engine-api/main.go` (new)
- File: `/app/wg-engine-api/go.mod` (new)
- Command: `cd /app/wg-engine-api && go mod init git.datadunia.com/hainzero/WGRplane/wg-engine-api`
@@ -106,26 +106,26 @@ iptables / ipset rules
### Phase B: WGRplane Base Setup (WGDashboard Parity)
-- [ ] **Task B1**: Review WGDashboard features for parity checklist
+- [x] **Task B1**: Review WGDashboard features for parity checklist
- Reference: Librarian findings (bg_f53966bd) — full feature list
- Features to implement: peer CRUD, QR codes, real-time monitoring, scheduling, TOTP auth, multi-server, plugins, i18n, themes
- File: `/app/README.md` (document feature parity status)
- QA: Checklist created with ALL WGDashboard features mapped to WGRplane implementation status
-- [ ] **Task B2**: Configure WGRplane to use port 10086 (WGDashboard default)
+- [x] **Task B2**: Configure WGRplane to use port 10086 (WGDashboard default)
- File: `/app/app.py` or `/app/config.json` (WGRplane config)
- Set: `app_port = 10086` (consistent with WGDashboard)
- Ensure: Does not conflict with `wg-engine-api` on port 10087
- QA: `curl http://localhost:10086` returns WGRplane dashboard page
- QA: Port 10086 in use by WGRplane, 10087 available for wg-engine-api
-- [ ] **Task B3**: Integrate WGRplane with existing WireGuard config path
+- [x] **Task B3**: Integrate WGRplane with existing WireGuard config path
- File: `/app/app.py` (WGRplane backend)
- Set WireGuard config path: `/etc/wireguard/wg0.conf` (consistent with existing scripts)
- QA: WGRplane can read `/etc/wireguard/wg0.conf` and list peers
- QA: WGRplane "Add Peer" creates valid WireGuard config entries
-- [ ] **Task B4**: Add policy.json API awareness to WGRplane frontend
+- [x] **Task B4**: Add policy.json API awareness to WGRplane frontend
- File: `/app/src/views/` or `/app/src/components/` (Vue.js components)
- Add: New UI section for "Policy API" (link to `http://localhost:10087/api/policy`)
- Note: WGRplane frontend will proxy or link to Go API (decision: proxy via Flask or direct link)
@@ -134,7 +134,7 @@ iptables / ipset rules
### Phase C: Go wg-engine-api Development
-- [ ] **Task C1**: Implement Go API server skeleton with routing
+- [x] **Task C1**: Implement Go API server skeleton with routing
- File: `/app/wg-engine-api/main.go`
- Framework: `github.com/gorilla/mux` (router)
- Port: **10087** (avoid conflict with WGDashboard's 10086)
@@ -143,7 +143,7 @@ iptables / ipset rules
- QA: `go build` succeeds, binary runs on port 10087
- QA: `curl -H "wg-rplane-datadunia: test" http://localhost:10087/api/policy` returns 200 or 401 (if auth enforced)
-- [ ] **Task C2**: Implement locking mechanism (flock) in Go
+- [x] **Task C2**: Implement locking mechanism (flock) in Go
- File: `/app/wg-engine-api/main.go` (lock function)
- Lock file: `/var/lock/wg-policy.lock` (SAME as existing scripts)
- Implementation: Use `syscall.Flock()` or exec `flock` command
@@ -151,7 +151,7 @@ iptables / ipset rules
- QA: Simultaneous API calls do not corrupt `policy.json`
- QA: Lock acquired within 10 seconds, else return 503 (timeout)
-- [ ] **Task C3**: Implement atomic write for policy.json in Go
+- [x] **Task C3**: Implement atomic write for policy.json in Go
- File: `/app/wg-engine-api/main.go` (write function)
- Pattern: Write to tmp file → `mv` (atomic, same filesystem)
- Reference: `wg-sync-policy.sh` lines 124-126: `mv -f "$tmp_policy" "$POLICY_FILE"`
@@ -159,7 +159,7 @@ iptables / ipset rules
- QA: `policy.json` never partially written (crash during write doesn't corrupt)
- QA: `jq empty /etc/wireguard/policy.json` validates JSON after write
-- [ ] **Task C4**: Add CLI flag for sync without HTTP server
+- [x] **Task C4**: Add CLI flag for sync without HTTP server
- File: `/app/wg-engine-api/main.go` (flag parsing)
- Flag: `--sync` (perform merge + write to `policy.json`, then exit)
- Use case: Called by `wg-policy.service` instead of `wg-sync-policy.sh`
@@ -168,7 +168,7 @@ iptables / ipset rules
### Phase D: Policy API Implementation
-- [ ] **Task D1**: Implement `GET /api/policy` (merged: API + #Access fallback)
+- [x] **Task D1**: Implement `GET /api/policy` (merged: API + #Access fallback)
- File: `/app/wg-engine-api/main.go` (GET handler)
- Step 1: Read API storage (`/etc/wireguard/api-policy.json`)
- Step 2: Parse `wg0.conf` for `#Access` comments (fallback, using Go or exec `wg-sync-policy.sh`)
@@ -178,7 +178,7 @@ iptables / ipset rules
- QA: Client with API policy + `#Access` → API policy wins in response
- QA: Client with ONLY `#Access` → fallback returns `#Access` value
-- [ ] **Task D2**: Implement `POST /api/policy` (update API-managed policy)
+- [x] **Task D2**: Implement `POST /api/policy` (update API-managed policy)
- File: `/app/wg-engine-api/main.go` (POST handler)
- Input: JSON body `{"ip": "10.0.0.2", "access": ["1.1.1.1/32"], "internet": true}`
- Validate: IP and CIDRs using Go validation functions (port from `wg-policy-lib.sh`)
@@ -188,14 +188,14 @@ iptables / ipset rules
- QA: `policy.json` updated with merged data (API overrides #Access)
- QA: `wg-policy-ctl rules` shows new targets after POST
-- [ ] **Task D3**: Implement `POST /api/reload` (trigger policy engine)
+- [x] **Task D3**: Implement `POST /api/reload` (trigger policy engine)
- File: `/app/wg-engine-api/main.go` (reload handler)
- Action: Exec `/usr/local/bin/wg-policy-engine.sh`
- Optional: Also exec `/usr/local/bin/wg-sync-policy.sh` first (if #Access fallback needed)
- QA: `curl -X POST -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/reload` returns 200
- QA: After reload, `wg-policy-ctl status` shows engine applied successfully
-- [ ] **Task D4**: Implement authentication middleware
+- [x] **Task D4**: Implement authentication middleware
- File: `/app/wg-engine-api/main.go` (middleware)
- Header: `wg-rplane-datadunia`
- Validation: Check header exists and matches configured token (from env or config file)
@@ -204,7 +204,7 @@ iptables / ipset rules
- QA: `curl -H "wg-rplane-datadunia: wrong" http://localhost:10087/api/policy` → 401
- QA: `curl -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/policy` → 200
-- [ ] **Task D5**: Create API storage file (`api-policy.json`) with schema
+- [x] **Task D5**: Create API storage file (`api-policy.json`) with schema
- File: `/etc/wireguard/api-policy.json` (new, API-managed)
- Schema: Same as `policy.json` but ONLY API-managed entries:
```json
@@ -224,7 +224,7 @@ iptables / ipset rules
### Phase E: #Access Migration & Merge Logic
-- [ ] **Task E1**: Implement #Access comment parser in Go (fallback)
+- [x] **Task E1**: Implement #Access comment parser in Go (fallback)
- File: `/app/wg-engine-api/main.go` (parse function)
- Method: Exec `wg-sync-policy.sh` OR parse `wg0.conf` directly in Go
- Prefer: Parse `wg0.conf` in Go (avoid exec dependency)
@@ -232,7 +232,7 @@ iptables / ipset rules
- QA: Go parser extracts same data as `wg-sync-policy.sh` awk script
- QA: `curl GET /api/policy` with no API policy returns `#Access` data correctly
-- [ ] **Task E2**: Implement merge logic (API overrides #Access)
+- [x] **Task E2**: Implement merge logic (API overrides #Access)
- File: `/app/wg-engine-api/main.go` (merge function)
- Logic: For each client IP:
1. Start with `#Access` parsed data (fallback)
@@ -242,13 +242,13 @@ iptables / ipset rules
- QA: Client with API policy `"access": ["1.1.1.1/32"]` + `#Access 2.2.2.2/32` → GET returns `["1.1.1.1/32"]`
- QA: Client with ONLY `#Access 2.2.2.2/32` → GET returns `["2.2.2.2/32"]`
-- [ ] **Task E3**: Handle `internet` flag merge
+- [x] **Task E3**: Handle `internet` flag merge
- File: `/app/wg-engine-api/main.go` (merge function extension)
- Logic: Same as access merge — API `internet` flag overrides `#Internet` comment
- QA: Client with API `internet: true` + no `#Internet` in wg0.conf → GET returns `true`
- QA: Client with API `internet: false` + `#Internet true` in wg0.conf → GET returns `false`
-- [ ] **Task E4**: Update `wg-policy.service` to use Go API sync (optional, recommended)
+- [x] **Task E4**: Update `wg-policy.service` to use Go API sync (optional, recommended)
- File: `wg-policy.service` (systemd unit)
- Change: `ExecStartPre` from `wg-sync-policy.sh` to `wg-engine-api --sync`
- Note: NOT modifying `.sh` scripts (only systemd unit)
@@ -257,21 +257,21 @@ iptables / ipset rules
### Phase F: Integration & Testing
-- [ ] **Task F1**: Add `bats` test framework for shell script validation
+- [x] **Task F1**: Add `bats` test framework for shell script validation
- File: `/tests/` (new directory) or use existing pattern
- Test cases: Policy.json validation, JSON structure, lock file behavior
- Install: `apt install bats` (add to `install.sh` if needed)
- QA: `bats /tests/policy.bats` passes all test cases
- QA: Test coverage for `wg-policy-ctl validate` command
-- [ ] **Task F2**: Add Go tests for `wg-engine-api`
+- [x] **Task F2**: Add Go tests for `wg-engine-api`
- File: `/app/wg-engine-api/main_test.go` (new)
- Test cases: Auth middleware, GET/POST handlers, merge logic, lock mechanism
- Run: `cd /app/wg-engine-api && go test ./...`
- QA: `go test` passes with >80% coverage
- QA: Mock `wg0.conf` and `api-policy.json` for isolated tests
-- [ ] **Task F3**: Integration test: Full flow validation
+- [x] **Task F3**: Integration test: Full flow validation
- Test: POST to API → policy.json updated → iptables rules applied
- Steps:
1. `curl -X POST ... http://localhost:10087/api/policy` (add client)
@@ -280,7 +280,7 @@ iptables / ipset rules
- QA: All 3 steps succeed in sequence
- QA: Fallback to `#Access` works when API has no entry for client
-- [ ] **Task F4**: Manual test documentation in README
+- [x] **Task F4**: Manual test documentation in README
- File: `/app/README.md` (test section)
- Document: How to run bats tests, Go tests, manual QA scenarios
- Note: No CI (none exists in repo), document manual commands
@@ -291,21 +291,21 @@ iptables / ipset rules
### Phase G: Documentation & Push
-- [ ] **Task G1**: Create comprehensive README.md for WGRplane submodule
+- [x] **Task G1**: Create comprehensive README.md for WGRplane submodule
- File: `/app/README.md` (new or update existing)
- Sections: Overview, Architecture, API Endpoints, Authentication, Integration with WGDashboard, Testing, Deployment
- Document: Go API endpoint (`http://localhost:10087/api/policy`), auth header `wg-rplane-datadunia`
- QA: README.md exists with all sections
- QA: `cat /app/README.md` shows complete documentation
-- [ ] **Task G2**: Document integration between WGRplane (Python) and wg-engine-api (Go)
+- [x] **Task G2**: Document integration between WGRplane (Python) and wg-engine-api (Go)
- File: `/app/README.md` (integration section)
- Explain: WGRplane on port 10086, Go API on port 10087
- Note: Frontend can proxy API requests or link directly
- QA: README has "Integration" section with port numbers and proxy examples
- QA: Developer understands how Python Flask talks to Go API
-- [ ] **Task G3**: Push WGRplane submodule to remote
+- [x] **Task G3**: Push WGRplane submodule to remote
- Commands:
```bash
cd /app
@@ -316,7 +316,7 @@ iptables / ipset rules
- QA: `git push` succeeds, remote updated
- QA: `git submodule status` in parent repo shows app with commit hash
-- [ ] **Task G4**: Update parent repo to reference pushed submodule
+- [x] **Task G4**: Update parent repo to reference pushed submodule
- Commands:
```bash
cd /path/to/03.wireguard-policy
diff --git a/app/plugins.go b/app/plugins.go
new file mode 100644
index 0000000..4ae7315
--- /dev/null
+++ b/app/plugins.go
@@ -0,0 +1,55 @@
+package main
+
+import "fmt"
+
+// Plugin defines a simple notification interface.
+type Plugin interface {
+ Notify(event string, payload interface{})
+}
+
+// TelegramNotifier is a mock notifier that would send a Telegram message in a real deployment.
+type TelegramNotifier struct{}
+
+func (t *TelegramNotifier) Notify(event string, payload interface{}) {
+ fmt.Printf("[TelegramNotifier] event=%s payload=%v\n", event, payload)
+}
+
+// SlackNotifier is a mock notifier that would send a Slack message in a real deployment.
+type SlackNotifier struct{}
+
+func (s *SlackNotifier) Notify(event string, payload interface{}) {
+ fmt.Printf("[SlackNotifier] event=%s payload=%v\n", event, payload)
+}
+
+// TrafficLogger logs traffic-related events for debugging/observability.
+type TrafficLogger struct{}
+
+func (l *TrafficLogger) Notify(event string, payload interface{}) {
+ fmt.Printf("[TrafficLogger] event=%s payload=%v\n", event, payload)
+}
+
+// PluginManager loads and triggers plugins.
+type PluginManager struct {
+ plugins []Plugin
+}
+
+// NewPluginManager creates a new PluginManager instance.
+func NewPluginManager() *PluginManager {
+ return &PluginManager{plugins: []Plugin{}}
+}
+
+// LoadPlugins initializes the built-in example plugins.
+func (pm *PluginManager) LoadPlugins() {
+ pm.plugins = []Plugin{
+ &TelegramNotifier{},
+ &SlackNotifier{},
+ &TrafficLogger{},
+ }
+}
+
+// Trigger dispatches an event to all registered plugins.
+func (pm *PluginManager) Trigger(event string, payload interface{}) {
+ for _, p := range pm.plugins {
+ p.Notify(event, payload)
+ }
+}
diff --git a/app/scheduler.go b/app/scheduler.go
new file mode 100644
index 0000000..50b244b
--- /dev/null
+++ b/app/scheduler.go
@@ -0,0 +1,95 @@
+package main
+
+import (
+ "log"
+ "time"
+
+ "github.com/robfig/cron/v3"
+)
+
+// initScheduler creates and starts the background cron jobs responsible for
+// peer lifecycle automation: expiry deletion, data-limit restriction, and monthly resets.
+func initScheduler() *cron.Cron {
+ c := cron.New(cron.WithSeconds())
+
+ // 2:00 AM daily - Delete expired peers
+ if _, err := c.AddFunc("0 0 2 * * *", deleteExpiredPeers); err != nil {
+ log.Printf("scheduler: failed to schedule deleteExpiredPeers: %v", err)
+ }
+
+ // 3:00 AM daily - Disable peers that exceeded data limit
+ if _, err := c.AddFunc("0 0 3 * * *", restrictOverLimitPeers); err != nil {
+ log.Printf("scheduler: failed to schedule restrictOverLimitPeers: %v", err)
+ }
+
+ // 1st day of every month at 00:00 - Reset data usage counters
+ if _, err := c.AddFunc("0 0 0 1 * *", resetMonthlyUsage); err != nil {
+ log.Printf("scheduler: failed to schedule resetMonthlyUsage: %v", err)
+ }
+
+ c.Start()
+ log.Println("scheduler: started background jobs (expiry, data-limit, monthly reset)")
+ return c
+}
+
+// deleteExpiredPeers removes peers whose ExpiresAt is non-zero and in the past.
+func deleteExpiredPeers() {
+ now := time.Now()
+ var peers []Peer
+ if err := db.Where("expires_at != 0 AND expires_at <= ?", now).Find(&peers).Error; err != nil {
+ log.Printf("scheduler: error querying expired peers: %v", err)
+ return
+ }
+ if len(peers) == 0 {
+ log.Println("scheduler: no expired peers to delete")
+ return
+ }
+ for _, p := range peers {
+ if err := db.Delete(&p).Error; err != nil {
+ log.Printf("scheduler: failed to delete peer %d: %v", p.ID, err)
+ } else {
+ log.Printf("scheduler: deleted expired peer %d", p.ID)
+ }
+ }
+}
+
+// restrictOverLimitPeers disables peers that have exceeded their data limit.
+func restrictOverLimitPeers() {
+ var peers []Peer
+ if err := db.Find(&peers).Error; err != nil {
+ log.Printf("scheduler: error loading peers for restriction: %v", err)
+ return
+ }
+ for _, p := range peers {
+ if p.Enabled && p.DataLimitGB > 0 {
+ limitBytes := p.DataLimitGB * 1_000_000_000
+ if p.CurrentDataUsageBytes >= limitBytes {
+ p.Enabled = false
+ if err := db.Save(&p).Error; err != nil {
+ log.Printf("scheduler: failed to disable peer %d: %v", p.ID, err)
+ } else {
+ log.Printf("scheduler: disabled peer %d due to data limit (%d/%d bytes)", p.ID, p.CurrentDataUsageBytes, limitBytes)
+ }
+ }
+ }
+ }
+}
+
+// resetMonthlyUsage resets all peers' CurrentDataUsageBytes to zero at the start of each month.
+func resetMonthlyUsage() {
+ var peers []Peer
+ if err := db.Find(&peers).Error; err != nil {
+ log.Printf("scheduler: error loading peers for monthly reset: %v", err)
+ return
+ }
+ for _, p := range peers {
+ if p.CurrentDataUsageBytes != 0 {
+ p.CurrentDataUsageBytes = 0
+ if err := db.Save(&p).Error; err != nil {
+ log.Printf("scheduler: failed to reset usage for peer %d: %v", p.ID, err)
+ } else {
+ log.Printf("scheduler: reset monthly usage for peer %d", p.ID)
+ }
+ }
+ }
+}
diff --git a/app/stats.go b/app/stats.go
new file mode 100644
index 0000000..b67a014
--- /dev/null
+++ b/app/stats.go
@@ -0,0 +1,158 @@
+package main
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "time"
+ "math/rand"
+ "github.com/gorilla/websocket"
+)
+
+// Client represents a single WebSocket connection.
+type Client struct {
+ hub *Hub
+ conn *websocket.Conn
+ send chan []byte
+}
+
+// Hub maintains the set of active clients and broadcasts messages to them.
+type Hub struct {
+ clients map[*Client]bool
+ broadcast chan []byte
+ register chan *Client
+ unregister chan *Client
+}
+
+func newHub() *Hub {
+ return &Hub{
+ clients: make(map[*Client]bool),
+ broadcast: make(chan []byte),
+ register: make(chan *Client),
+ unregister: make(chan *Client),
+ }
+}
+
+func (h *Hub) run() {
+ for {
+ select {
+ case c := <-h.register:
+ h.clients[c] = true
+ case c := <-h.unregister:
+ if _, ok := h.clients[c]; ok {
+ delete(h.clients, c)
+ close(c.send)
+ }
+ case message := <-h.broadcast:
+ for c := range h.clients {
+ select {
+ case c.send <- message:
+ default:
+ close(c.send)
+ delete(h.clients, c)
+ }
+ }
+ }
+ }
+}
+
+const (
+ writeWait = 10 * time.Second
+ pongWait = 60 * time.Second
+ pingPeriod = (pongWait * 9) / 10
+)
+
+var upgrader = websocket.Upgrader{
+ CheckOrigin: func(r *http.Request) bool { return true },
+}
+
+func (c *Client) writePump() {
+ ticker := time.NewTicker(pingPeriod)
+ defer func() {
+ ticker.Stop()
+ c.conn.Close()
+ }()
+ for {
+ select {
+ case message, ok := <-c.send:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if !ok {
+ c.conn.WriteMessage(websocket.CloseMessage, []byte{})
+ return
+ }
+ w, err := c.conn.NextWriter(websocket.TextMessage)
+ if err != nil {
+ return
+ }
+ w.Write(message)
+ if err := w.Close(); err != nil {
+ return
+ }
+ case <-ticker.C:
+ c.conn.SetWriteDeadline(time.Now().Add(writeWait))
+ if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
+ return
+ }
+ }
+ }
+}
+
+func (c *Client) readPump() {
+ defer func() {
+ c.hub.unregister <- c
+ c.conn.Close()
+ }()
+ c.conn.SetReadLimit(5120)
+ c.conn.SetReadDeadline(time.Now().Add(pongWait))
+ c.conn.SetPongHandler(func(string) error { c.conn.SetReadDeadline(time.Now().Add(pongWait)); return nil })
+ for {
+ _, _, err := c.conn.ReadMessage()
+ if err != nil {
+ break
+ }
+ }
+}
+
+func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) {
+ ws, err := upgrader.Upgrade(w, r, nil)
+ if err != nil {
+ log.Println("WebSocket upgrade error:", err)
+ return
+ }
+ client := &Client{hub: hub, conn: ws, send: make(chan []byte, 256)}
+ client.hub.register <- client
+
+ go client.writePump()
+ go client.readPump()
+}
+
+// Stats payload shape
+type Stats struct {
+ TotalRx int `json:"total_rx"`
+ TotalTx int `json:"total_tx"`
+ PeersOnline int `json:"peers_online"`
+ PeersOffline int `json:"peers_offline"`
+}
+
+// startStatsBroadcast periodically emits mock stats to all connected clients.
+func startStatsBroadcast(hub *Hub) {
+ rand.Seed(time.Now().UnixNano())
+ var totalRx, totalTx int
+ ticker := time.NewTicker(5 * time.Second)
+ go func() {
+ for {
+ <-ticker.C
+ totalRx += rand.Intn(120)
+ totalTx += rand.Intn(150)
+ online := rand.Intn(5) + 1
+ offline := 5 - online
+ s := Stats{TotalRx: totalRx, TotalTx: totalTx, PeersOnline: online, PeersOffline: offline}
+ payload, err := json.Marshal(s)
+ if err != nil {
+ log.Println("Stats marshal error:", err)
+ continue
+ }
+ hub.broadcast <- payload
+ }
+ }()
+}
diff --git a/app/validation.go b/app/validation.go
new file mode 100644
index 0000000..b1afbcb
--- /dev/null
+++ b/app/validation.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "encoding/base64"
+ "net"
+)
+
+// ValidateIP returns true if the input is a valid IPv4 or IPv6 address.
+func ValidateIP(ip string) bool {
+ if ip == "" {
+ return false
+ }
+ return net.ParseIP(ip) != nil
+}
+
+// ValidateCIDR returns true if the input is a valid CIDR notation (e.g., 10.0.0.0/24).
+func ValidateCIDR(cidr string) bool {
+ if cidr == "" {
+ return false
+ }
+ // net.ParseCIDR validates CIDR; it also returns an IP, which we don't need here.
+ if _, _, err := net.ParseCIDR(cidr); err != nil {
+ return false
+ }
+ return true
+}
+
+// ValidatePublicKey returns true if the provided WireGuard public key is a valid base64-encoded 32-byte value.
+func ValidatePublicKey(key string) bool {
+ if key == "" {
+ return false
+ }
+ // Try to decode as base64 without padding (RawStdEncoding).
+ if b, err := base64.RawStdEncoding.DecodeString(key); err == nil && len(b) == 32 {
+ return true
+ }
+ // Fallback to standard base64 decoding with padding if present.
+ if b, err := base64.StdEncoding.DecodeString(key); err == nil && len(b) == 32 {
+ return true
+ }
+ return false
+}
diff --git a/app/wgrplane.db b/app/wgrplane.db
new file mode 100644
index 0000000..a5df8cb
Binary files /dev/null and b/app/wgrplane.db differ
Connect with us
-Join the Vite community
--- GitHub
- - Discord
- - X.com
- - Bluesky
-
-