feat(app): add WGRplane web UI and backend features

- Add Vue 3 frontend with glassmorphism design (Tailwind CSS)
- Add Go backend handlers: auth, webhooks, stats, scheduler, validation
- Add i18n support (EN, ID, ZH)
- Add Swagger docs and API handlers
- Add nftables integration and plugins support
- Remove deprecated go.mod (migrated to wgrplane)
This commit is contained in:
datadunia
2026-05-03 23:20:34 +07:00
parent 3eae539a56
commit 6d8899a078
54 changed files with 7395 additions and 434 deletions
+15
View File
@@ -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"]
+4
View File
@@ -0,0 +1,4 @@
{
"greeting": "Hello",
"farewell": "Goodbye"
}
+4
View File
@@ -0,0 +1,4 @@
{
"greeting": "Halo",
"farewell": "Selamat tinggal"
}
+4
View File
@@ -0,0 +1,4 @@
{
"greeting": "你好",
"farewell": "再见"
}
+146
View File
@@ -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
}
+1244
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+777
View File
@@ -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"
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>WGRplane - WireGuard Control</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+675 -22
View File
@@ -11,19 +11,38 @@
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"vue": "^3.5.33",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
"vue-sonner": "^2.0.9" "vue-sonner": "^2.0.9"
}, },
"devDependencies": { "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", "typescript": "~6.0.2",
"vite": "^8.0.10" "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": { "node_modules/@babel/helper-string-parser": {
"version": "7.27.1", "version": "7.27.1",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -31,7 +50,6 @@
"node_modules/@babel/helper-validator-identifier": { "node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5", "version": "7.28.5",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
@@ -39,7 +57,6 @@
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.29.3", "version": "7.29.3",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/types": "^7.29.0" "@babel/types": "^7.29.0"
}, },
@@ -53,7 +70,6 @@
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.29.0", "version": "7.29.0",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.27.1", "@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5" "@babel/helper-validator-identifier": "^7.28.5"
@@ -111,10 +127,96 @@
"vue": "^3.2.0" "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": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5", "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", "license": "MIT",
"peer": true "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
}, },
"node_modules/@kurkle/color": { "node_modules/@kurkle/color": {
"version": "0.3.4", "version": "0.3.4",
@@ -427,6 +529,289 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@tanstack/virtual-core": {
"version": "3.14.0", "version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
@@ -470,10 +855,33 @@
"integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==",
"license": "MIT" "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": { "node_modules/@vue/compiler-core": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.2", "@babel/parser": "^7.29.2",
"@vue/shared": "3.5.33", "@vue/shared": "3.5.33",
@@ -485,7 +893,6 @@
"node_modules/@vue/compiler-dom": { "node_modules/@vue/compiler-dom": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-core": "3.5.33", "@vue/compiler-core": "3.5.33",
"@vue/shared": "3.5.33" "@vue/shared": "3.5.33"
@@ -494,7 +901,6 @@
"node_modules/@vue/compiler-sfc": { "node_modules/@vue/compiler-sfc": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/parser": "^7.29.2", "@babel/parser": "^7.29.2",
"@vue/compiler-core": "3.5.33", "@vue/compiler-core": "3.5.33",
@@ -510,7 +916,6 @@
"node_modules/@vue/compiler-ssr": { "node_modules/@vue/compiler-ssr": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.33", "@vue/compiler-dom": "3.5.33",
"@vue/shared": "3.5.33" "@vue/shared": "3.5.33"
@@ -525,7 +930,6 @@
"node_modules/@vue/reactivity": { "node_modules/@vue/reactivity": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/shared": "3.5.33" "@vue/shared": "3.5.33"
} }
@@ -533,7 +937,6 @@
"node_modules/@vue/runtime-core": { "node_modules/@vue/runtime-core": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.33", "@vue/reactivity": "3.5.33",
"@vue/shared": "3.5.33" "@vue/shared": "3.5.33"
@@ -542,7 +945,6 @@
"node_modules/@vue/runtime-dom": { "node_modules/@vue/runtime-dom": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/reactivity": "3.5.33", "@vue/reactivity": "3.5.33",
"@vue/runtime-core": "3.5.33", "@vue/runtime-core": "3.5.33",
@@ -553,7 +955,6 @@
"node_modules/@vue/server-renderer": { "node_modules/@vue/server-renderer": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-ssr": "3.5.33", "@vue/compiler-ssr": "3.5.33",
"@vue/shared": "3.5.33" "@vue/shared": "3.5.33"
@@ -564,8 +965,7 @@
}, },
"node_modules/@vue/shared": { "node_modules/@vue/shared": {
"version": "3.5.33", "version": "3.5.33",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/@vueuse/core": { "node_modules/@vueuse/core": {
"version": "14.3.0", "version": "14.3.0",
@@ -605,6 +1005,111 @@
"vue": "^3.5.0" "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": { "node_modules/chart.js": {
"version": "4.5.1", "version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
@@ -619,8 +1124,7 @@
}, },
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
@@ -630,10 +1134,30 @@
"node": ">=8" "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": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
}, },
@@ -641,10 +1165,19 @@
"url": "https://github.com/fb55/entities?sponsor=1" "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": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "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": "^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": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"dev": true, "dev": true,
@@ -949,7 +1513,6 @@
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
@@ -970,6 +1533,13 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"license": "ISC" "license": "ISC"
@@ -987,6 +1557,8 @@
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.13", "version": "8.5.13",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz",
"integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -1011,6 +1583,13 @@
"node": "^10 || ^12 || >=14" "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": { "node_modules/rolldown": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"dev": true, "dev": true,
@@ -1050,6 +1629,27 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"dev": true, "dev": true,
@@ -1085,6 +1685,37 @@
"node": ">=14.17" "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": { "node_modules/vite": {
"version": "8.0.10", "version": "8.0.10",
"dev": true, "dev": true,
@@ -1163,8 +1794,9 @@
}, },
"node_modules/vue": { "node_modules/vue": {
"version": "3.5.33", "version": "3.5.33",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.33.tgz",
"integrity": "sha512-1AgChhx5w3ALgT4oK3acm2Es/7jyZhWSVUfs3rOBlGQC0rjEDkS7G4lWlJJGGNQD+BV3reCwbQrOe1mPNwKHBQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.33", "@vue/compiler-dom": "3.5.33",
"@vue/compiler-sfc": "3.5.33", "@vue/compiler-sfc": "3.5.33",
@@ -1191,6 +1823,27 @@
"vue": "^3.0.0-0 || ^2.7.0" "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": { "node_modules/vue-router": {
"version": "4.6.4", "version": "4.6.4",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+7
View File
@@ -9,6 +9,11 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"devDependencies": { "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", "typescript": "~6.0.2",
"vite": "^8.0.10" "vite": "^8.0.10"
}, },
@@ -16,7 +21,9 @@
"@headlessui/vue": "^1.7.23", "@headlessui/vue": "^1.7.23",
"@vueuse/core": "^14.3.0", "@vueuse/core": "^14.3.0",
"chart.js": "^4.5.1", "chart.js": "^4.5.1",
"vue": "^3.5.33",
"vue-chartjs": "^5.3.3", "vue-chartjs": "^5.3.3",
"vue-i18n": "^9.14.5",
"vue-router": "^4.6.4", "vue-router": "^4.6.4",
"vue-sonner": "^2.0.9" "vue-sonner": "^2.0.9"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
export default { export default {
plugins: { plugins: {
tailwindcss: {}, '@tailwindcss/postcss': {},
autoprefixer: {}, autoprefixer: {},
}, },
} }
+161
View File
@@ -0,0 +1,161 @@
<template>
<div class="app-container">
<!-- Mobile Top Navbar -->
<header class="top-navbar lg:hidden">
<div class="flex items-center justify-between p-4">
<button @click="toggleSidebar" class="text-white/80 hover:text-cyan-400 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
</button>
<h1 class="text-xl font-bold text-cyan-400">WGRplane</h1>
<div class="w-6"></div>
</div>
</header>
<!-- Sidebar -->
<aside
class="sidebar"
:class="{ '-translate-x-full': !sidebarOpen, 'translate-x-0': sidebarOpen, 'lg:translate-x-0': true }"
>
<div class="sidebar-header">
<h1 class="text-2xl font-bold text-cyan-400">WGRplane</h1>
<p class="text-sm text-white/50">WireGuard Control</p>
</div>
<nav class="sidebar-nav">
<router-link
to="/"
class="nav-item"
:class="{ 'active': $route.path === '/' }"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
<span>Dashboard</span>
</router-link>
<router-link
to="/settings"
class="nav-item"
:class="{ 'active': $route.path === '/settings' }"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>Settings</span>
</router-link>
</nav>
</aside>
<!-- Overlay for mobile sidebar -->
<div
v-if="sidebarOpen"
class="sidebar-overlay lg:hidden"
@click="toggleSidebar"
></div>
<!-- Main Content -->
<main class="main-content" :class="{ 'lg:ml-64': true }">
<router-view />
</main>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const sidebarOpen = ref(false)
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
</script>
<style scoped>
.app-container {
min-height: 100vh;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
}
.top-navbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 40;
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px);
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: 16rem;
z-index: 50;
background: rgba(15, 23, 42, 0.9);
backdrop-filter: blur(16px);
border-right: 1px solid rgba(255, 255, 255, 0.1);
transition: transform 0.3s ease-in-out;
display: flex;
flex-direction: column;
padding: 1.5rem;
}
.sidebar-header {
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.nav-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
color: rgba(255, 255, 255, 0.7);
text-decoration: none;
transition: all 0.2s ease;
}
.nav-item:hover {
background: rgba(255, 255, 255, 0.1);
color: #22d3ee;
}
.nav-item.active {
background: rgba(6, 182, 212, 0.2);
color: #22d3ee;
border: 1px solid rgba(34, 211, 238, 0.3);
}
.sidebar-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 45;
}
.main-content {
padding-top: 4rem;
padding: 1.5rem;
min-height: 100vh;
}
@media (min-width: 1024px) {
.main-content {
padding-top: 1.5rem;
margin-left: 16rem;
}
}
</style>
@@ -0,0 +1,61 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-white/70 mb-1">Subscribed Actions</label>
<div class="grid grid-cols-2 gap-2">
<label
v-for="action in availableActions"
:key="action.value"
class="flex items-center gap-2 p-2 bg-white/5 rounded-lg border border-white/10 hover:border-cyan-400/50 transition-colors cursor-pointer"
>
<input
type="checkbox"
:value="action.value"
:checked="modelValue.includes(action.value)"
@change="toggleAction(action.value)"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
/>
<span class="text-sm text-white">{{ action.label }}</span>
</label>
</div>
</div>
</template>
<script setup lang="ts">
interface Action {
label: string
value: string
}
const props = defineProps<{
modelValue: string[]
availableActions?: Action[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const defaultActions: Action[] = [
{ label: 'Peer Connected', value: 'peer.connected' },
{ label: 'Peer Disconnected', value: 'peer.disconnected' },
{ label: 'Peer Added', value: 'peer.added' },
{ label: 'Peer Removed', value: 'peer.removed' },
{ label: 'Policy Updated', value: 'policy.updated' },
{ label: 'Server Started', value: 'server.started' },
{ label: 'Server Stopped', value: 'server.stopped' },
{ label: 'Login Failed', value: 'auth.failed' }
]
const availableActions = props.availableActions || defaultActions
const toggleAction = (value: string) => {
const current = [...props.modelValue]
const index = current.indexOf(value)
if (index === -1) {
current.push(value)
} else {
current.splice(index, 1)
}
emit('update:modelValue', current)
}
</script>
@@ -0,0 +1,93 @@
<template>
<div class="space-y-2">
<div class="flex gap-2">
<input
v-model="inputValue"
type="text"
:placeholder="placeholder || 'Add CIDR (e.g., 192.168.1.0/24)'"
class="flex-1 bg-white/5 backdrop-blur-sm border border-white/10 rounded-lg px-4 py-2 text-white placeholder-white/30 focus:outline-none focus:border-cyan-400/50 transition-colors"
@keydown.enter="addCIDR"
/>
<button
@click="addCIDR"
class="px-4 py-2 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 shadow-xl hover:bg-white/20 hover:border-cyan-400/50 transition-all duration-300 text-cyan-400"
>
Add
</button>
</div>
<p v-if="error" class="text-red-400 text-sm">{{ error }}</p>
<div class="flex flex-wrap gap-2">
<div
v-for="(cidr, index) in modelValue"
:key="index"
class="flex items-center gap-1 px-3 py-1 bg-white/10 backdrop-blur-md border border-white/20 rounded-full text-white/90 text-sm hover:border-cyan-400/30 transition-colors"
>
<span>{{ cidr }}</span>
<button
@click="removeCIDR(index)"
class="text-white/50 hover:text-red-400 transition-colors ml-1"
>
&times;
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
modelValue: string[]
placeholder?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const inputValue = ref('')
const error = ref('')
const validateCIDR = (cidr: string): boolean => {
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/(\d{1,2})$/
if (!cidrRegex.test(cidr)) {
error.value = 'Invalid CIDR format. Use e.g., 192.168.1.0/24'
return false
}
const [ip, prefixStr] = cidr.split('/')
const octets = ip.split('.').map(Number)
const prefix = Number(prefixStr)
if (octets.some(octet => octet < 0 || octet > 255)) {
error.value = 'Invalid IP octet (must be 0-255)'
return false
}
if (prefix < 0 || prefix > 32) {
error.value = 'Invalid prefix (must be 0-32)'
return false
}
error.value = ''
return true
}
const addCIDR = () => {
const trimmed = inputValue.value.trim()
if (!trimmed) return
if (props.modelValue.includes(trimmed)) {
error.value = 'CIDR already exists'
return
}
if (validateCIDR(trimmed)) {
emit('update:modelValue', [...props.modelValue, trimmed])
inputValue.value = ''
error.value = ''
}
}
const removeCIDR = (index: number) => {
const newList = [...props.modelValue]
newList.splice(index, 1)
emit('update:modelValue', newList)
}
</script>
@@ -0,0 +1,43 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-white/70 mb-1">Custom Body (JSON)</label>
<textarea
:value="modelValue"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
rows="6"
placeholder='{ "text": "Webhook triggered", "event": "{{event}}" }'
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-cyan-500 resize-y"
></textarea>
<p v-if="error" class="text-sm text-red-400">{{ error }}</p>
<p class="text-xs text-white/50">
Use {{event}}, {{timestamp}}, {{peer}} as placeholders for dynamic values.
</p>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
const error = ref('')
watch(() => props.modelValue, (val) => {
if (!val.trim()) {
error.value = ''
return
}
try {
JSON.parse(val)
error.value = ''
} catch (e) {
error.value = 'Invalid JSON format'
}
}, { immediate: true })
</script>
@@ -0,0 +1,76 @@
<template>
<div class="space-y-2">
<label class="block text-sm font-medium text-white/70 mb-1">Headers</label>
<div
v-for="(header, index) in headers"
:key="index"
class="flex items-center gap-2"
>
<input
type="text"
placeholder="Key"
:value="header.key"
@input="updateHeader(index, 'key', ($event.target as HTMLInputElement).value)"
class="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-1.5 text-white placeholder-white/50 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<input
type="text"
placeholder="Value"
:value="header.value"
@input="updateHeader(index, 'value', ($event.target as HTMLInputElement).value)"
class="flex-1 bg-white/5 border border-white/20 rounded-lg px-3 py-1.5 text-white placeholder-white/50 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
<button
@click="removeHeader(index)"
class="p-1.5 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
title="Remove header"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<button
@click="addHeader"
class="text-sm text-cyan-400 hover:text-cyan-300 flex items-center gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Header
</button>
</div>
</template>
<script setup lang="ts">
interface Header {
key: string
value: string
}
const props = defineProps<{
modelValue: Header[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: Header[]): void
}>()
const headers = props.modelValue.length ? props.modelValue : [{ key: '', value: '' }]
const addHeader = () => {
emit('update:modelValue', [...props.modelValue, { key: '', value: '' }])
}
const removeHeader = (index: number) => {
const newHeaders = [...props.modelValue]
newHeaders.splice(index, 1)
emit('update:modelValue', newHeaders.length ? newHeaders : [{ key: '', value: '' }])
}
const updateHeader = (index: number, field: keyof Header, value: string) => {
const newHeaders = [...props.modelValue]
newHeaders[index] = { ...newHeaders[index], [field]: value }
emit('update:modelValue', newHeaders)
}
</script>
@@ -0,0 +1,29 @@
<template>
<label class="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
class="sr-only peer"
:checked="modelValue"
@change="emit('update:modelValue', ($event.target as HTMLInputElement).checked)"
/>
<div
class="w-12 h-6 rounded-full transition-colors duration-300 backdrop-blur-sm border"
:class="modelValue ? 'bg-cyan-500/30 border-cyan-400/50' : 'bg-gray-700/50 border-white/10'"
></div>
<div
class="absolute top-0.5 left-0.5 w-5 h-5 rounded-full bg-white shadow-md transition-transform duration-300"
:class="modelValue ? 'translate-x-6 bg-cyan-400' : 'translate-x-0 bg-gray-300'"
></div>
<span class="ml-3 text-white/90 text-sm">{{ modelValue ? 'Allowed' : 'Blocked' }}</span>
</label>
</template>
<script setup lang="ts">
defineProps<{
modelValue: boolean
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
}>()
</script>
+402
View File
@@ -0,0 +1,402 @@
<template>
<GlassCard class="p-6 overflow-x-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-cyan-400">Peer Management</h2>
<GlassButton @click="isAddPeerModalOpen = true" class="from-cyan-500 to-blue-500">
+ Add Peer
</GlassButton>
</div>
<table class="w-full text-left text-white/90">
<thead>
<tr class="border-b border-white/10">
<th class="p-3 text-white/70">Peer Name</th>
<th class="p-3 text-white/70">Public Key</th>
<th class="p-3 text-white/70">IP Address</th>
<th class="p-3 text-white/70">Allow Access (CIDRs)</th>
<th class="p-3 text-white/70">Allow Internet</th>
<th class="p-3 text-white/70">Expiry Date</th>
<th class="p-3 text-white/70">Data Usage</th>
<th class="p-3 text-white/70">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="peer in peers" :key="peer.id" class="border-b border-white/5 hover:bg-white/5 transition-colors">
<td class="p-3">{{ peer.name }}</td>
<td class="p-3 font-mono text-sm text-white/70 truncate max-w-[200px]" :title="peer.publicKey">{{ peer.publicKey }}</td>
<td class="p-3 font-mono text-sm text-white/70">{{ peer.ip }}</td>
<td class="p-3">
<CIDRTagInput v-model="peer.allowedAccess" placeholder="Add CIDR" />
</td>
<td class="p-3">
<InternetToggle v-model="peer.allowInternet" />
</td>
<td class="p-3 text-white/70">
{{ peer.expiresAt ? new Date(peer.expiresAt).toLocaleDateString() : 'Never' }}
</td>
<td class="p-3 text-white/70">
{{ peer.currentDataUsageBytes ? formatBytes(peer.currentDataUsageBytes) : '0 B' }}
</td>
<td class="p-3 flex gap-2">
<button @click="openEditPeer(peer)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-yellow-400/50 transition-all text-yellow-400 text-sm">
Edit
</button>
<button @click="showQRCode(peer)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-cyan-400/50 transition-all text-cyan-400 text-sm">
QR Code
</button>
<button @click="downloadConfig(peer.id)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-green-400/50 transition-all text-green-400 text-sm">
Config
</button>
<button @click="deletePeer(peer.id)" class="px-3 py-1 bg-white/10 backdrop-blur-md rounded-lg border border-white/20 hover:bg-white/20 hover:border-red-400/50 transition-all text-red-400 text-sm">
Delete
</button>
</td>
</tr>
</tbody>
</table>
<!-- Add Peer Modal -->
<TransitionRoot appear :show="isAddPeerModalOpen" as="template">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isAddPeerModalOpen = false" />
</TransitionChild>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Add New Peer</DialogTitle>
<div class="space-y-4">
<div>
<label class="block text-white/70 mb-1">Public Key</label>
<GlassInput v-model="newPeer.publicKey" placeholder="Paste Public Key (or auto-generate)" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">IP Address</label>
<GlassInput v-model="newPeer.ip" placeholder="e.g., 10.0.0.5/32" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">Allow Access (CIDRs)</label>
<CIDRTagInput v-model="newPeer.allowedAccess" placeholder="Add CIDR (e.g., 192.168.1.0/24)" />
</div>
<div class="flex items-center justify-between">
<span class="text-white/70">Allow Internet</span>
<InternetToggle v-model="newPeer.allowInternet" />
</div>
<div>
<label class="block text-white/70 mb-1">Expiry Date</label>
<input
type="date"
v-model="newPeer.expiresAt"
class="w-full px-4 py-2 bg-white/5 backdrop-blur-md rounded-lg border border-white/20 text-white/90 focus:outline-none focus:border-cyan-400/50 transition-all"
/>
</div>
<div>
<label class="block text-white/70 mb-1">Data Limit (GB)</label>
<GlassInput
v-model.number="newPeer.dataLimitGB"
type="number"
min="0"
step="0.1"
placeholder="Leave empty for no limit"
class="w-full"
/>
</div>
</div>
<div class="flex gap-4 mt-6 justify-end">
<GlassButton @click="isAddPeerModalOpen = false" class="from-gray-500 to-gray-600">Cancel</GlassButton>
<GlassButton @click="addPeer" class="from-cyan-500 to-blue-500">Add Peer</GlassButton>
</div>
</DialogPanel>
</TransitionChild>
</TransitionRoot>
<!-- Edit Peer Modal -->
<TransitionRoot appear :show="isEditPeerModalOpen" as="template">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isEditPeerModalOpen = false" />
</TransitionChild>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-lg rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Edit Peer</DialogTitle>
<div class="space-y-4" v-if="selectedPeerForEdit">
<div>
<label class="block text-white/70 mb-1">Public Key</label>
<GlassInput v-model="selectedPeerForEdit.publicKey" placeholder="Public Key" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">IP Address</label>
<GlassInput v-model="selectedPeerForEdit.ip" placeholder="e.g., 10.0.0.5/32" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">Allow Access (CIDRs)</label>
<CIDRTagInput v-model="selectedPeerForEdit.allowedAccess" placeholder="Add CIDR (e.g., 192.168.1.0/24)" />
</div>
<div class="flex items-center justify-between">
<span class="text-white/70">Allow Internet</span>
<InternetToggle v-model="selectedPeerForEdit.allowInternet" />
</div>
<div>
<label class="block text-white/70 mb-1">Expiry Date</label>
<input
type="date"
v-model="selectedPeerForEdit.expiresAt"
class="w-full px-4 py-2 bg-white/5 backdrop-blur-md rounded-lg border border-white/20 text-white/90 focus:outline-none focus:border-cyan-400/50 transition-all"
/>
</div>
<div>
<label class="block text-white/70 mb-1">Data Limit (GB)</label>
<GlassInput
v-model.number="selectedPeerForEdit.dataLimitGB"
type="number"
min="0"
step="0.1"
placeholder="Leave empty for no limit"
class="w-full"
/>
</div>
</div>
<div class="flex gap-4 mt-6 justify-end">
<GlassButton @click="isEditPeerModalOpen = false" class="from-gray-500 to-gray-600">Cancel</GlassButton>
<GlassButton @click="updatePeer" class="from-cyan-500 to-blue-500">Save Changes</GlassButton>
</div>
</DialogPanel>
</TransitionChild>
</TransitionRoot>
<!-- QR Code Modal -->
<TransitionRoot appear :show="isQRCodeModalOpen" as="template">
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0"
enter-to="opacity-100"
leave="duration-200 ease-in"
leave-from="opacity-100"
leave-to="opacity-0"
>
<div class="fixed inset-0 bg-black/70 backdrop-blur-sm" @click="isQRCodeModalOpen = false" />
</TransitionChild>
<TransitionChild
as="template"
enter="duration-300 ease-out"
enter-from="opacity-0 scale-95"
enter-to="opacity-100 scale-100"
leave="duration-200 ease-in"
leave-from="opacity-100 scale-100"
leave-to="opacity-0 scale-95"
>
<DialogPanel class="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-2xl bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl p-6 border border-white/20 shadow-xl">
<DialogTitle class="text-lg font-medium text-cyan-400 mb-4">Peer QR Code</DialogTitle>
<div class="flex flex-col items-center gap-4">
<img
v-if="selectedPeerForQR"
:src="`/api/peers/${selectedPeerForQR.id}/qrcode`"
alt="Peer QR Code"
class="w-64 h-64 bg-white p-4 rounded-lg"
/>
<p v-if="selectedPeerForQR" class="text-white/70 text-sm text-center">{{ selectedPeerForQR.name }}</p>
</div>
<div class="flex justify-end mt-6">
<GlassButton @click="isQRCodeModalOpen = false" class="from-gray-500 to-gray-600">Close</GlassButton>
</div>
</DialogPanel>
</TransitionChild>
</TransitionRoot>
</GlassCard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { TransitionRoot, TransitionChild, Dialog, DialogPanel, DialogTitle } from '@headlessui/vue'
import GlassCard from './glass/GlassCard.vue'
import GlassInput from './glass/GlassInput.vue'
import GlassButton from './glass/GlassButton.vue'
import CIDRTagInput from './CIDRTagInput.vue'
import InternetToggle from './InternetToggle.vue'
interface Peer {
id: string
name: string
publicKey: string
ip: string
allowedAccess: string[]
allowInternet: boolean
expiresAt?: string | null
dataLimitGB?: number | null
currentDataUsageBytes?: number | null
}
const props = defineProps<{
serverId: string
}>()
const peers = ref<Peer[]>([
{
id: '1',
name: 'Client 1',
publicKey: 'xTIBA5rboUvnH3h2+JQ0qf0pI0n6KJ7g5Uc5gG0q0=',
ip: '10.0.0.2/32',
allowedAccess: ['192.168.1.0/24', '10.0.0.1/32'],
allowInternet: true
},
{
id: '2',
name: 'Client 2',
publicKey: 'yUIBA6scpVwoH4h3+KR1qf1qJ1o7KJ8h6Vd6hH1r1=',
ip: '10.0.0.3/32',
allowedAccess: [],
allowInternet: false
},
{
id: '3',
name: 'Client 3',
publicKey: 'zVJCB7tdqWxpI5h4+LS2rg2rK2p8LK9i7We7iI2s2=',
ip: '10.0.0.4/32',
allowedAccess: ['172.16.0.0/16'],
allowInternet: true
}
])
const isAddPeerModalOpen = ref(false)
const isQRCodeModalOpen = ref(false)
const isEditPeerModalOpen = ref(false)
const selectedPeerForQR = ref<Peer | null>(null)
const selectedPeerForEdit = ref<Peer | null>(null)
const newPeer = ref({
publicKey: '',
ip: '',
allowedAccess: [] as string[],
allowInternet: false,
expiresAt: null as string | null,
dataLimitGB: null as number | null
})
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const openEditPeer = (peer: Peer) => {
selectedPeerForEdit.value = { ...peer }
isEditPeerModalOpen.value = true
}
const updatePeer = async () => {
if (!selectedPeerForEdit.value) return
try {
const peer = selectedPeerForEdit.value
const response = await fetch(`/api/peers/${peer.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
publicKey: peer.publicKey,
ip: peer.ip,
allowedAccess: peer.allowedAccess,
allowInternet: peer.allowInternet,
expiresAt: peer.expiresAt,
dataLimitGB: peer.dataLimitGB
})
})
if (!response.ok) throw new Error('Failed to update peer')
const updatedPeer = await response.json()
const index = peers.value.findIndex(p => p.id === updatedPeer.id)
if (index !== -1) {
peers.value[index] = updatedPeer
}
isEditPeerModalOpen.value = false
selectedPeerForEdit.value = null
} catch (error) {
console.error('Update peer error:', error)
alert('Failed to update peer. Check console for details.')
}
}
const addPeer = async () => {
try {
const response = await fetch(`/api/servers/${props.serverId}/peers`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newPeer.value)
})
if (!response.ok) throw new Error('Failed to add peer')
const createdPeer = await response.json()
peers.value.push({ ...createdPeer, name: `Client ${peers.value.length + 1}` })
isAddPeerModalOpen.value = false
newPeer.value = { publicKey: '', ip: '', allowedAccess: [], allowInternet: false, expiresAt: null, dataLimitGB: null }
} catch (error) {
console.error('Add peer error:', error)
alert('Failed to add peer. Check console for details.')
}
}
const deletePeer = async (id: string) => {
if (!confirm('Are you sure you want to delete this peer?')) return
try {
const response = await fetch(`/api/peers/${id}`, { method: 'DELETE' })
if (!response.ok) throw new Error('Failed to delete peer')
peers.value = peers.value.filter(peer => peer.id !== id)
} catch (error) {
console.error('Delete peer error:', error)
alert('Failed to delete peer. Check console for details.')
}
}
const showQRCode = (peer: Peer) => {
selectedPeerForQR.value = peer
isQRCodeModalOpen.value = true
}
const downloadConfig = async (id: string) => {
try {
const response = await fetch(`/api/peers/${id}/config`)
if (!response.ok) throw new Error('Failed to download config')
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `peer-${id}.conf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (error) {
console.error('Download config error:', error)
alert('Failed to download config. Check console for details.')
}
}
</script>
@@ -0,0 +1,26 @@
<template>
<div class="space-y-1">
<label class="block text-sm font-medium text-white/70 mb-1">Template</label>
<select
:value="modelValue"
@change="emit('update:modelValue', ($event.target as HTMLSelectElement).value)"
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-cyan-500 appearance-none bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2020%2020%22%20fill%3D%22%23ffffff%22%3E%3Cpath%20fill-rule%3D%22evenodd%22%20d%3D%22M5.23%207.21a.75.75%200%20011.06.02L10%2011.168l3.71-3.938a.75.75%200%20111.08%201.04l-4.25%204.5a.75.75%200%2001-1.08%200l-4.25-4.5a.75.75%200%20011.06-1.06z%22%20clip-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E')] bg-[length:20px] bg-[right_8px_center] bg-no-repeat"
>
<option value="" class="bg-gray-900">No Template (Custom Body)</option>
<option value="slack" class="bg-gray-900">Slack Message</option>
<option value="discord" class="bg-gray-900">Discord Embed</option>
<option value="telegram" class="bg-gray-900">Telegram Message</option>
<option value="generic" class="bg-gray-900">Generic JSON</option>
</select>
</div>
</template>
<script setup lang="ts">
defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void
}>()
</script>
+127
View File
@@ -0,0 +1,127 @@
<template>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div>
<label class="block text-sm font-medium text-white/70 mb-1">Name</label>
<input
v-model="form.name"
type="text"
required
placeholder="My Webhook"
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<div>
<label class="block text-sm font-medium text-white/70 mb-1">URL</label>
<input
v-model="form.url"
type="url"
required
placeholder="https://example.com/webhook"
class="w-full bg-white/5 border border-white/20 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-cyan-500"
/>
</div>
<TemplateDropdown v-model="form.template" />
<CustomBodyEditor
v-if="!form.template"
v-model="form.customBody"
/>
<HeaderKeyValue v-model="form.headers" />
<ActionCheckboxes v-model="form.subscribedActions" />
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 text-white/70 text-sm">
<input
v-model="form.verifySSL"
type="checkbox"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
/>
Verify SSL
</label>
<label class="flex items-center gap-2 text-white/70 text-sm">
<input
v-model="form.enabled"
type="checkbox"
class="w-4 h-4 rounded border-white/20 bg-white/5 text-cyan-500 focus:ring-cyan-500 focus:ring-offset-0"
/>
Enabled
</label>
</div>
<div class="flex gap-3 pt-2">
<button
type="submit"
class="px-6 py-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-lg hover:shadow-cyan-500/50 hover:scale-105 transition-all duration-300"
>
{{ submitLabel }}
</button>
<button
v-if="showCancel"
type="button"
@click="$emit('cancel')"
class="px-6 py-2 bg-white/10 text-white rounded-lg hover:bg-white/20 transition-colors"
>
Cancel
</button>
</div>
</form>
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import type { Webhook, WebhookHeader } from '../types/webhook'
import TemplateDropdown from './TemplateDropdown.vue'
import CustomBodyEditor from './CustomBodyEditor.vue'
import HeaderKeyValue from './HeaderKeyValue.vue'
import ActionCheckboxes from './ActionCheckboxes.vue'
const props = defineProps<{
webhook?: Webhook
submitLabel?: string
showCancel?: boolean
}>()
const emit = defineEmits<{
(e: 'submit', webhook: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>): void
(e: 'cancel'): void
}>()
const getDefaultForm = (): Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'> => ({
name: '',
url: '',
template: '',
customBody: '',
headers: [{ key: '', value: '' }],
subscribedActions: [],
verifySSL: true,
enabled: true
})
const form = reactive<Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>>(getDefaultForm())
watch(() => props.webhook, (newWebhook) => {
if (newWebhook) {
Object.assign(form, {
name: newWebhook.name,
url: newWebhook.url,
template: newWebhook.template,
customBody: newWebhook.customBody,
headers: [...newWebhook.headers],
subscribedActions: [...newWebhook.subscribedActions],
verifySSL: newWebhook.verifySSL,
enabled: newWebhook.enabled
})
} else {
Object.assign(form, getDefaultForm())
}
}, { immediate: true })
const handleSubmit = () => {
emit('submit', { ...form })
}
</script>
@@ -0,0 +1,92 @@
<template>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h3 class="text-lg font-semibold text-white">Configured Webhooks</h3>
<button
@click="$emit('add')"
class="px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-500 text-white rounded-lg hover:shadow-cyan-500/50 hover:scale-105 transition-all duration-300 flex items-center gap-2"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Add Webhook
</button>
</div>
<div v-if="webhooks.length === 0" class="text-center py-8 text-white/50">
No webhooks configured yet. Click "Add Webhook" to create one.
</div>
<div v-else class="space-y-3">
<div
v-for="webhook in webhooks"
:key="webhook.id"
class="bg-white/5 backdrop-blur-md border border-white/10 rounded-xl p-4 hover:border-cyan-400/50 transition-all duration-300"
>
<div class="flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
<h4 class="font-medium text-white">{{ webhook.name }}</h4>
<span
v-if="webhook.enabled"
class="px-2 py-0.5 text-xs bg-green-500/20 text-green-400 rounded-full"
>
Enabled
</span>
<span
v-else
class="px-2 py-0.5 text-xs bg-red-500/20 text-red-400 rounded-full"
>
Disabled
</span>
<span
v-if="webhook.template"
class="px-2 py-0.5 text-xs bg-blue-500/20 text-blue-400 rounded-full"
>
{{ webhook.template }}
</span>
</div>
<p class="text-sm text-white/70">{{ webhook.url }}</p>
<p class="text-xs text-white/50">
Subscribed to: {{ webhook.subscribedActions.join(', ') || 'None' }}
</p>
</div>
<div class="flex gap-2">
<button
@click="$emit('edit', webhook)"
class="p-2 text-cyan-400 hover:text-cyan-300 hover:bg-cyan-500/10 rounded-lg transition-colors"
title="Edit webhook"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
</button>
<button
@click="$emit('delete', webhook.id)"
class="p-2 text-red-400 hover:text-red-300 hover:bg-red-500/10 rounded-lg transition-colors"
title="Delete webhook"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Webhook } from '../types/webhook'
defineProps<{
webhooks: Webhook[]
}>()
defineEmits<{
(e: 'add'): void
(e: 'edit', webhook: Webhook): void
(e: 'delete', id: string): void
}>()
</script>
@@ -1,6 +1,6 @@
<template> <template>
<button <button
class="px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:opacity-90 transition-opacity" class="px-6 py-2 bg-gradient-to-r dark:from-blue-500 dark:to-purple-600 from-blue-600 to-purple-700 text-white rounded-lg hover:opacity-90 transition-opacity"
v-bind="$attrs" v-bind="$attrs"
> >
<slot></slot> <slot></slot>
@@ -1,5 +1,5 @@
<template> <template>
<div class="bg-white/10 backdrop-blur-md border border-white/20 rounded-xl shadow-glass p-6"> <div class="dark:bg-white/10 dark:border-white/20 bg-gray-100/80 border-gray-200/50 backdrop-blur-md rounded-xl shadow-glass p-6">
<slot></slot> <slot></slot>
</div> </div>
</template> </template>
@@ -1,6 +1,6 @@
<template> <template>
<input <input
class="w-full bg-white/5 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500" class="w-full dark:bg-white/5 dark:border-white/20 bg-gray-100 border-gray-300 rounded-lg px-4 py-2 dark:text-white text-gray-900 dark:placeholder-white/50 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
v-bind="$attrs" v-bind="$attrs"
/> />
</template> </template>
@@ -1,7 +1,7 @@
<template> <template>
<button <button
class="relative w-12 h-6 rounded-full transition-colors" class="relative w-12 h-6 rounded-full transition-colors"
:class="modelValue ? 'bg-blue-500' : 'bg-gray-600'" :class="modelValue ? 'bg-blue-500' : 'dark:bg-gray-600 bg-gray-300'"
@click="$emit('update:modelValue', !modelValue)" @click="$emit('update:modelValue', !modelValue)"
> >
<span <span
+36
View File
@@ -0,0 +1,36 @@
import { watch } from 'vue'
import { useDark, useStorage } from '@vueuse/core'
export type Theme = 'dark' | 'light' | 'auto'
const theme = useStorage<Theme>('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
}
}
}
@@ -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<boolean>
stats: Ref<WebSocketStats>
error: Ref<string | null>
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<WebSocketStats>({ ...DEFAULT_STATS })
const error = ref<string | null>(null)
let ws: WebSocket | null = null
let reconnectTimer: ReturnType<typeof setTimeout> | 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
}
}
+15
View File
@@ -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
}
})
+29
View File
@@ -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"
}
+29
View File
@@ -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"
}
+29
View File
@@ -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": "保存设置"
}
+8 -58
View File
@@ -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 './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<HTMLDivElement>('#app')!.innerHTML = ` const app = createApp(App)
<section id="center"> app.use(router)
<div class="hero"> app.use(i18n)
<img src="${heroImg}" class="base" width="170" height="179"> app.mount('#app')
<img src="${typescriptLogo}" class="framework" alt="TypeScript logo"/>
<img src="${viteLogo}" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/main.ts</code> and save to test <code>HMR</code></p>
</div>
<button id="counter" type="button" class="counter"></button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" src="${viteLogo}" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://www.typescriptlang.org" target="_blank">
<img class="button-icon" src="${typescriptLogo}" alt="">
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
<li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
<li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
<li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
`
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
+45
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+6 -289
View File
@@ -1,296 +1,13 @@
:root { @tailwind base;
--text: #6b6375; @tailwind components;
--text-h: #08060d; @tailwind utilities;
--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);
}
}
body { body {
margin: 0; margin: 0;
} background: #0f172a;
color: #fff;
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);
}
} }
#app { #app {
width: 1126px; min-height: 100vh;
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);
}
} }
+47
View File
@@ -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'
}
]
+188
View File
@@ -0,0 +1,188 @@
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-3xl font-bold text-white">Dashboard</h1>
<div class="flex items-center gap-2">
<div
class="w-3 h-3 rounded-full"
:class="isConnected ? 'bg-green-400 animate-pulse' : 'bg-red-400'"
></div>
<span class="text-sm text-white/70">
{{ isConnected ? 'Live' : 'Disconnected' }}
</span>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<GlassCard class="p-6">
<div class="flex items-center justify-between mb-2">
<p class="text-white/50 text-sm">Total Peers</p>
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
</div>
<p class="text-3xl font-bold text-white">{{ stats.totalPeers }}</p>
</GlassCard>
<GlassCard class="p-6">
<div class="flex items-center justify-between mb-2">
<p class="text-white/50 text-sm">Active Peers</p>
<svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<p class="text-3xl font-bold text-green-400">{{ stats.activePeers }}</p>
</GlassCard>
<GlassCard class="p-6">
<div class="flex items-center justify-between mb-2">
<p class="text-white/50 text-sm">Active Rules</p>
<svg class="w-5 h-5 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<p class="text-3xl font-bold text-purple-400">{{ stats.totalRules }}</p>
</GlassCard>
</div>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Traffic Overview</h2>
<div class="h-64">
<Line v-if="chartData.datasets[0].data.length > 0" :data="chartData" :options="chartOptions" />
<div v-else class="flex items-center justify-center h-full text-white/30">
Waiting for data...
</div>
</div>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Peer Status</h2>
<div v-if="stats.peers.length > 0" class="space-y-2 max-h-64 overflow-y-auto">
<div
v-for="peer in stats.peers"
:key="peer.publicKey"
class="flex items-center justify-between p-3 bg-white/5 rounded-lg"
>
<div class="flex items-center gap-3">
<div
class="w-2 h-2 rounded-full"
:class="peer.isOnline ? 'bg-green-400' : 'bg-gray-500'"
></div>
<span class="text-white font-mono text-sm">{{ peer.ip }}</span>
</div>
<div class="text-white/50 text-xs">
RX: {{ formatBytes(peer.rxBytes) }} | TX: {{ formatBytes(peer.txBytes) }}
</div>
</div>
</div>
<p v-else class="text-white/30">No peer data available</p>
</GlassCard>
<div v-if="error" class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
{{ error }}
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { Line } from 'vue-chartjs'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
import GlassCard from '../components/glass/GlassCard.vue'
import { useWebSocket } from '../composables/useWebSocket'
ChartJS.register(
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
)
const { isConnected, stats, error, connect } = useWebSocket('ws://localhost:8080/ws/stats')
onMounted(() => {
connect()
})
const chartData = computed(() => {
const history = stats.value.trafficHistory || []
return {
labels: history.map((_, i) => `${i}s`),
datasets: [
{
label: 'Download (RX)',
data: history.map(p => p.rxBytes),
borderColor: 'rgb(34, 211, 238)',
backgroundColor: 'rgba(34, 211, 238, 0.1)',
fill: true,
tension: 0.4
},
{
label: 'Upload (TX)',
data: history.map(p => p.txBytes),
borderColor: 'rgb(168, 85, 247)',
backgroundColor: 'rgba(168, 85, 247, 0.1)',
fill: true,
tension: 0.4
}
]
}
})
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: 'rgba(255, 255, 255, 0.7)'
}
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: '#fff',
bodyColor: '#fff',
callbacks: {
label: (context: any) => {
return `${context.dataset.label}: ${formatBytes(context.parsed.y)}`
}
}
}
},
scales: {
x: {
ticks: { color: 'rgba(255, 255, 255, 0.5)' },
grid: { color: 'rgba(255, 255, 255, 0.1)' }
},
y: {
ticks: {
color: 'rgba(255, 255, 255, 0.5)',
callback: (value: number) => formatBytes(value)
},
grid: { color: 'rgba(255, 255, 255, 0.1)' }
}
}
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
</script>
+20
View File
@@ -0,0 +1,20 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<GlassButton @click="$router.back()" class="!px-3 !py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</GlassButton>
<h1 class="text-3xl font-bold text-white">Peer Management</h1>
</div>
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
<PeerTable :server-id="$route.params.id" />
</div>
</template>
<script setup lang="ts">
import GlassButton from '../components/glass/GlassButton.vue'
import PeerTable from '../components/PeerTable.vue'
</script>
@@ -0,0 +1,28 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<GlassButton @click="$router.back()" class="!px-3 !py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</GlassButton>
<h1 class="text-3xl font-bold text-white">Server Detail</h1>
</div>
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Server Information</h2>
<p class="text-white/50">Server details will be displayed here.</p>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Peer Table</h2>
<p class="text-white/50">Peer list for this server will be displayed here.</p>
</GlassCard>
</div>
</template>
<script setup lang="ts">
import GlassCard from '../components/glass/GlassCard.vue'
import GlassButton from '../components/glass/GlassButton.vue'
</script>
+160
View File
@@ -0,0 +1,160 @@
<template>
<div class="space-y-6">
<h1 class="text-3xl font-bold text-white">{{ t('settings') }}</h1>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('dashboardSettings') }}</h2>
<p class="text-white/50">Dashboard configuration options will be displayed here.</p>
</GlassCard>
<!-- Language Settings -->
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('language') }}</h2>
<div class="flex gap-4">
<GlassButton
@click="switchLanguage('en')"
:class="locale === 'en' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('english') }}
</GlassButton>
<GlassButton
@click="switchLanguage('id')"
:class="locale === 'id' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('indonesian') }}
</GlassButton>
<GlassButton
@click="switchLanguage('zh')"
:class="locale === 'zh' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('chinese') }}
</GlassButton>
</div>
</GlassCard>
<!-- Theme Settings -->
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('theme') }}</h2>
<div class="flex gap-4">
<GlassButton
@click="toggleTheme('dark')"
:class="theme === 'dark' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('dark') }}
</GlassButton>
<GlassButton
@click="toggleTheme('light')"
:class="theme === 'light' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('light') }}
</GlassButton>
<GlassButton
@click="toggleTheme('auto')"
:class="theme === 'auto' ? 'from-cyan-500 to-blue-500' : 'from-gray-600 to-gray-700'"
class="px-4 py-2"
>
{{ t('auto') }}
</GlassButton>
</div>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('totpSetup') }}</h2>
<p class="text-white/50">Two-factor authentication setup will be displayed here.</p>
</GlassCard>
<GlassCard class="p-6">
<h2 class="text-xl font-semibold text-cyan-400 mb-4">{{ t('smtpSettings') }}</h2>
<div class="flex items-center justify-between mb-6">
<span class="text-white">{{ t('enableSMTP') }}</span>
<GlassToggle v-model="smtpSettings.Enabled" />
</div>
<div class="space-y-4">
<div>
<label class="block text-white/70 mb-1">{{ t('server') }}</label>
<GlassInput v-model="smtpSettings.Server" placeholder="smtp.example.com" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('port') }}</label>
<GlassInput v-model="smtpSettings.Port" type="number" placeholder="587" class="w-full" />
</div>
<div class="flex items-center justify-between">
<span class="text-white/70">{{ t('useTLS') }}</span>
<GlassToggle v-model="smtpSettings.UseTLS" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('username') }}</label>
<GlassInput v-model="smtpSettings.Username" placeholder="user@example.com" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('password') }}</label>
<GlassInput v-model="smtpSettings.Password" type="password" placeholder="SMTP Password" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('fromEmail') }}</label>
<GlassInput v-model="smtpSettings.FromEmail" type="email" placeholder="noreply@example.com" class="w-full" />
</div>
<div>
<label class="block text-white/70 mb-1">{{ t('fromName') }}</label>
<GlassInput v-model="smtpSettings.FromName" placeholder="WireGuard VPN" class="w-full" />
</div>
</div>
<div class="flex gap-4 mt-6">
<GlassButton @click="testEmail" class="from-cyan-500 to-blue-500">{{ t('testEmail') }}</GlassButton>
<GlassButton @click="saveSettings" class="from-green-500 to-teal-500">{{ t('saveSettings') }}</GlassButton>
</div>
</GlassCard>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import GlassCard from '../components/glass/GlassCard.vue'
import GlassInput from '../components/glass/GlassInput.vue'
import GlassToggle from '../components/glass/GlassToggle.vue'
import GlassButton from '../components/glass/GlassButton.vue'
import { useTheme } from '../composables/useTheme'
const { t, locale } = useI18n()
const { theme, toggleTheme } = useTheme()
const smtpSettings = reactive({
Enabled: false,
Server: '',
Port: 587,
UseTLS: true,
Username: '',
Password: '',
FromEmail: '',
FromName: ''
})
const switchLanguage = (lang: string) => {
locale.value = lang
}
const testEmail = () => {
console.log('Test Email with settings:', JSON.parse(JSON.stringify(smtpSettings)))
alert('Test email functionality (mock) - check console for settings')
}
const saveSettings = () => {
console.log('Saving SMTP settings (mock):', JSON.parse(JSON.stringify(smtpSettings)))
alert('SMTP settings saved (mock)')
}
</script>
+125
View File
@@ -0,0 +1,125 @@
<template>
<div class="space-y-6">
<div class="flex items-center gap-4">
<GlassButton @click="$router.back()" class="!px-3 !py-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
</GlassButton>
<h1 class="text-3xl font-bold text-white">Webhook Management</h1>
</div>
<p class="text-white/70">Server ID: {{ $route.params.id }}</p>
<GlassCard class="p-6">
<WebhookList
:webhooks="webhooks"
@add="showAddForm"
@edit="showEditForm"
@delete="deleteWebhook"
/>
</GlassCard>
<!-- Add/Edit Form Modal -->
<Teleport to="body">
<Transition name="modal">
<div
v-if="showForm"
class="fixed inset-0 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4 z-50"
@click.self="closeForm"
>
<div class="bg-gradient-to-br from-gray-900/90 to-gray-800/90 backdrop-blur-xl rounded-2xl p-6 border border-white/20 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center mb-4">
<h2 class="text-xl font-semibold text-cyan-400">
{{ editingWebhook ? 'Edit Webhook' : 'Add New Webhook' }}
</h2>
<button
@click="closeForm"
class="p-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<WebhookForm
:webhook="editingWebhook"
:submit-label="editingWebhook ? 'Update Webhook' : 'Create Webhook'"
:show-cancel="true"
@submit="saveWebhook"
@cancel="closeForm"
/>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import GlassCard from '../components/glass/GlassCard.vue'
import GlassButton from '../components/glass/GlassButton.vue'
import WebhookList from '../components/WebhookList.vue'
import WebhookForm from '../components/WebhookForm.vue'
import { mockWebhooks, type Webhook } from '../types/webhook'
const webhooks = ref<Webhook[]>([...mockWebhooks])
const showForm = ref(false)
const editingWebhook = ref<Webhook | undefined>(undefined)
const showAddForm = () => {
editingWebhook.value = undefined
showForm.value = true
}
const showEditForm = (webhook: Webhook) => {
editingWebhook.value = webhook
showForm.value = true
}
const closeForm = () => {
showForm.value = false
editingWebhook.value = undefined
}
const saveWebhook = (webhookData: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>) => {
if (editingWebhook.value) {
// Update existing webhook
const index = webhooks.value.findIndex(w => w.id === editingWebhook.value!.id)
if (index !== -1) {
webhooks.value[index] = {
...webhooks.value[index],
...webhookData,
updatedAt: new Date().toISOString()
}
}
} else {
// Add new webhook
const newWebhook: Webhook = {
...webhookData,
id: Date.now().toString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
webhooks.value.push(newWebhook)
}
closeForm()
}
const deleteWebhook = (id: string) => {
if (confirm('Are you sure you want to delete this webhook?')) {
webhooks.value = webhooks.value.filter(w => w.id !== id)
}
}
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>
+1
View File
@@ -3,6 +3,7 @@ export default {
"./index.html", "./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}", "./src/**/*.{vue,js,ts,jsx,tsx}",
], ],
darkMode: 'class',
theme: { theme: {
extend: { extend: {
backdropBlur: { backdropBlur: {
+6
View File
@@ -0,0 +1,6 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
-15
View File
@@ -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
)
+41 -6
View File
@@ -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 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 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 h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A= 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-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 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 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= 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 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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=
+785
View File
@@ -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)
}
+48
View File
@@ -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
}
+28 -28
View File
@@ -82,21 +82,21 @@ iptables / ipset rules
### Phase A: Submodule Initialization ### Phase A: Submodule Initialization
- [ ] **Task A1**: Initialize WGRplane submodule at `/app` - [x] **Task A1**: Initialize WGRplane submodule at `/app`
- File: `/app` (new submodule directory) - File: `/app` (new submodule directory)
- Command: `git submodule add https://git.datadunia.com/hainzero/WGRplane.git app` - Command: `git submodule add https://git.datadunia.com/hainzero/WGRplane.git app`
- Followed by: `git submodule update --init --recursive` - Followed by: `git submodule update --init --recursive`
- QA: `git submodule status` shows `app` with commit hash, no errors - QA: `git submodule status` shows `app` with commit hash, no errors
- QA: `/app` directory exists with WGRplane files (Python/Flask backend, Vue.js frontend) - 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) - File: `/app` (submodule contents)
- Inspect: `ls /app` — should contain Python backend, Vue.js frontend, requirements.txt - Inspect: `ls /app` — should contain Python backend, Vue.js frontend, requirements.txt
- Verify WGDashboard-equivalent structure: `app.py` or similar Flask entry point - Verify WGDashboard-equivalent structure: `app.py` or similar Flask entry point
- QA: WGRplane files present, Python/Flask + Vue.js stack confirmed - QA: WGRplane files present, Python/Flask + Vue.js stack confirmed
- QA: `cat /app/requirements.txt` shows Flask, SQLite, other dependencies - 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/main.go` (new)
- File: `/app/wg-engine-api/go.mod` (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` - 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) ### 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 - 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 - 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) - File: `/app/README.md` (document feature parity status)
- QA: Checklist created with ALL WGDashboard features mapped to WGRplane implementation 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) - File: `/app/app.py` or `/app/config.json` (WGRplane config)
- Set: `app_port = 10086` (consistent with WGDashboard) - Set: `app_port = 10086` (consistent with WGDashboard)
- Ensure: Does not conflict with `wg-engine-api` on port 10087 - Ensure: Does not conflict with `wg-engine-api` on port 10087
- QA: `curl http://localhost:10086` returns WGRplane dashboard page - QA: `curl http://localhost:10086` returns WGRplane dashboard page
- QA: Port 10086 in use by WGRplane, 10087 available for wg-engine-api - 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) - File: `/app/app.py` (WGRplane backend)
- Set WireGuard config path: `/etc/wireguard/wg0.conf` (consistent with existing scripts) - 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 can read `/etc/wireguard/wg0.conf` and list peers
- QA: WGRplane "Add Peer" creates valid WireGuard config entries - 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) - 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`) - 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) - 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 ### 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` - File: `/app/wg-engine-api/main.go`
- Framework: `github.com/gorilla/mux` (router) - Framework: `github.com/gorilla/mux` (router)
- Port: **10087** (avoid conflict with WGDashboard's 10086) - 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: `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) - 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) - File: `/app/wg-engine-api/main.go` (lock function)
- Lock file: `/var/lock/wg-policy.lock` (SAME as existing scripts) - Lock file: `/var/lock/wg-policy.lock` (SAME as existing scripts)
- Implementation: Use `syscall.Flock()` or exec `flock` command - 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: Simultaneous API calls do not corrupt `policy.json`
- QA: Lock acquired within 10 seconds, else return 503 (timeout) - 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) - File: `/app/wg-engine-api/main.go` (write function)
- Pattern: Write to tmp file → `mv` (atomic, same filesystem) - Pattern: Write to tmp file → `mv` (atomic, same filesystem)
- Reference: `wg-sync-policy.sh` lines 124-126: `mv -f "$tmp_policy" "$POLICY_FILE"` - 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: `policy.json` never partially written (crash during write doesn't corrupt)
- QA: `jq empty /etc/wireguard/policy.json` validates JSON after write - 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) - File: `/app/wg-engine-api/main.go` (flag parsing)
- Flag: `--sync` (perform merge + write to `policy.json`, then exit) - Flag: `--sync` (perform merge + write to `policy.json`, then exit)
- Use case: Called by `wg-policy.service` instead of `wg-sync-policy.sh` - 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 ### 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) - File: `/app/wg-engine-api/main.go` (GET handler)
- Step 1: Read API storage (`/etc/wireguard/api-policy.json`) - 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`) - 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 API policy + `#Access` → API policy wins in response
- QA: Client with ONLY `#Access` → fallback returns `#Access` value - 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) - 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}` - 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`) - 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: `policy.json` updated with merged data (API overrides #Access)
- QA: `wg-policy-ctl rules` shows new targets after POST - 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) - File: `/app/wg-engine-api/main.go` (reload handler)
- Action: Exec `/usr/local/bin/wg-policy-engine.sh` - Action: Exec `/usr/local/bin/wg-policy-engine.sh`
- Optional: Also exec `/usr/local/bin/wg-sync-policy.sh` first (if #Access fallback needed) - 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: `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 - 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) - File: `/app/wg-engine-api/main.go` (middleware)
- Header: `wg-rplane-datadunia` - Header: `wg-rplane-datadunia`
- Validation: Check header exists and matches configured token (from env or config file) - 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: wrong" http://localhost:10087/api/policy` → 401
- QA: `curl -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/policy` → 200 - 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) - File: `/etc/wireguard/api-policy.json` (new, API-managed)
- Schema: Same as `policy.json` but ONLY API-managed entries: - Schema: Same as `policy.json` but ONLY API-managed entries:
```json ```json
@@ -224,7 +224,7 @@ iptables / ipset rules
### Phase E: #Access Migration & Merge Logic ### 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) - File: `/app/wg-engine-api/main.go` (parse function)
- Method: Exec `wg-sync-policy.sh` OR parse `wg0.conf` directly in Go - Method: Exec `wg-sync-policy.sh` OR parse `wg0.conf` directly in Go
- Prefer: Parse `wg0.conf` in Go (avoid exec dependency) - 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: 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 - 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) - File: `/app/wg-engine-api/main.go` (merge function)
- Logic: For each client IP: - Logic: For each client IP:
1. Start with `#Access` parsed data (fallback) 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 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"]` - 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) - File: `/app/wg-engine-api/main.go` (merge function extension)
- Logic: Same as access merge — API `internet` flag overrides `#Internet` comment - 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: true` + no `#Internet` in wg0.conf → GET returns `true`
- QA: Client with API `internet: false` + `#Internet true` in wg0.conf → GET returns `false` - 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) - File: `wg-policy.service` (systemd unit)
- Change: `ExecStartPre` from `wg-sync-policy.sh` to `wg-engine-api --sync` - Change: `ExecStartPre` from `wg-sync-policy.sh` to `wg-engine-api --sync`
- Note: NOT modifying `.sh` scripts (only systemd unit) - Note: NOT modifying `.sh` scripts (only systemd unit)
@@ -257,21 +257,21 @@ iptables / ipset rules
### Phase F: Integration & Testing ### 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 - File: `/tests/` (new directory) or use existing pattern
- Test cases: Policy.json validation, JSON structure, lock file behavior - Test cases: Policy.json validation, JSON structure, lock file behavior
- Install: `apt install bats` (add to `install.sh` if needed) - Install: `apt install bats` (add to `install.sh` if needed)
- QA: `bats /tests/policy.bats` passes all test cases - QA: `bats /tests/policy.bats` passes all test cases
- QA: Test coverage for `wg-policy-ctl validate` command - 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) - File: `/app/wg-engine-api/main_test.go` (new)
- Test cases: Auth middleware, GET/POST handlers, merge logic, lock mechanism - Test cases: Auth middleware, GET/POST handlers, merge logic, lock mechanism
- Run: `cd /app/wg-engine-api && go test ./...` - Run: `cd /app/wg-engine-api && go test ./...`
- QA: `go test` passes with >80% coverage - QA: `go test` passes with >80% coverage
- QA: Mock `wg0.conf` and `api-policy.json` for isolated tests - 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 - Test: POST to API → policy.json updated → iptables rules applied
- Steps: - Steps:
1. `curl -X POST ... http://localhost:10087/api/policy` (add client) 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: All 3 steps succeed in sequence
- QA: Fallback to `#Access` works when API has no entry for client - 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) - File: `/app/README.md` (test section)
- Document: How to run bats tests, Go tests, manual QA scenarios - Document: How to run bats tests, Go tests, manual QA scenarios
- Note: No CI (none exists in repo), document manual commands - Note: No CI (none exists in repo), document manual commands
@@ -291,21 +291,21 @@ iptables / ipset rules
### Phase G: Documentation & Push ### 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) - File: `/app/README.md` (new or update existing)
- Sections: Overview, Architecture, API Endpoints, Authentication, Integration with WGDashboard, Testing, Deployment - 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` - Document: Go API endpoint (`http://localhost:10087/api/policy`), auth header `wg-rplane-datadunia`
- QA: README.md exists with all sections - QA: README.md exists with all sections
- QA: `cat /app/README.md` shows complete documentation - 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) - File: `/app/README.md` (integration section)
- Explain: WGRplane on port 10086, Go API on port 10087 - Explain: WGRplane on port 10086, Go API on port 10087
- Note: Frontend can proxy API requests or link directly - Note: Frontend can proxy API requests or link directly
- QA: README has "Integration" section with port numbers and proxy examples - QA: README has "Integration" section with port numbers and proxy examples
- QA: Developer understands how Python Flask talks to Go API - 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: - Commands:
```bash ```bash
cd /app cd /app
@@ -316,7 +316,7 @@ iptables / ipset rules
- QA: `git push` succeeds, remote updated - QA: `git push` succeeds, remote updated
- QA: `git submodule status` in parent repo shows app with commit hash - 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: - Commands:
```bash ```bash
cd /path/to/03.wireguard-policy cd /path/to/03.wireguard-policy
+55
View File
@@ -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)
}
}
+95
View File
@@ -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)
}
}
}
}
+158
View File
@@ -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
}
}()
}
+42
View File
@@ -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
}
BIN
View File
Binary file not shown.