Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb525879f1 | |||
| b1c7f10fec | |||
| da09fb2e65 | |||
| fefaf49298 | |||
| fc05ac0725 | |||
| 5bcac141d1 | |||
| c7ad30d2df | |||
| 93f6aaea76 | |||
| 7a448ffa3a | |||
| 89443f9481 | |||
| c5d3651a96 | |||
| 548e3949ad | |||
| 4b8d5008d6 | |||
| 25e0cfd15e | |||
| 9c771f098c | |||
| dfc8d9ed57 | |||
| 565d7f1a35 | |||
| 45c7788d1d | |||
| b9fbe1430f | |||
| 5eec7651da | |||
| c8cf771bfa | |||
| ba7b9ac64c | |||
| c42683e968 | |||
| 4a3d099ff9 | |||
| 64e8372fc1 | |||
| d7b1de1885 | |||
| 7d0c0c18d0 | |||
| 8d237b293f | |||
| 43477a3333 | |||
| 42a35302b3 | |||
| 6d9fa80b54 | |||
| a59ebd4813 | |||
| 0a7bf72d6c | |||
| 861d910363 | |||
| d0d529450c | |||
| 0c6bac4c22 | |||
| 7b02527200 | |||
| fa4bf318b6 | |||
| cdd22860b3 | |||
| bbae401002 | |||
| fb198f64bb | |||
| d62936d607 | |||
| 3b0f478d3a | |||
| b39d7a4bd2 | |||
| 81af99dfc4 | |||
| 139463855d | |||
| 309a3fea8f | |||
| 1fdb8135db | |||
| 22b9133431 | |||
| 6e9d389cbf | |||
| 644c6e2dde | |||
| 1716747c09 | |||
| 47abc63658 | |||
| 44c480a89a | |||
| 35dcc2f15f | |||
| f71ef8c3e4 | |||
| 6308017b24 | |||
| 5b4f91e6e0 | |||
| f6af1fb6d8 | |||
| f47b50b857 | |||
| e2c807dc12 | |||
| 144be82e9b | |||
| 0feed113ac | |||
| 20478191a3 | |||
| 32f1e78ee1 | |||
| ac32038ebb | |||
| 417fd99dbf | |||
| 1633fede7a | |||
| 14742b4c27 | |||
| c49964d4d4 | |||
| 4c00af7c44 | |||
| de21d422b6 | |||
| 581350971a | |||
| 8a8927c8e8 | |||
| 282b2a3b69 | |||
| fc1e5513aa | |||
| 481807769e | |||
| 52e629ac02 | |||
| b3676b87cb |
@@ -0,0 +1,15 @@
|
||||
name: Beta Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*-beta*'
|
||||
- 'v*-test*'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: "contains(github.ref_name, 'beta') || contains(github.ref_name, 'test')"
|
||||
uses: ./.gitea/workflows/deploy_call.yaml
|
||||
with:
|
||||
prerelease: true
|
||||
secrets: inherit
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
prerelease:
|
||||
description: 'Mark as prerelease'
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone repository with submodules
|
||||
run: |
|
||||
git config --global --remove-section http || true
|
||||
git config --global --unset-all core.askPass || true
|
||||
TOKEN="${{ secrets.BUILD_TOKEN }}"
|
||||
git clone --recurse-submodules \
|
||||
-c credential.helper="" \
|
||||
https://token:$TOKEN@git.datadunia.com/devops/wireguard-vpn.git .
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build Vue frontend
|
||||
run: |
|
||||
cd app/frontend
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: Build Go binaries
|
||||
run: |
|
||||
cd app
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../wgrplane-linux-amd64 .
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ../wgrplane-linux-arm64 .
|
||||
|
||||
- name: Generate latest.json
|
||||
run: |
|
||||
VERSION="${{ gitea.ref_name }}"
|
||||
RELEASE_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||
REPO="${{ gitea.repository }}"
|
||||
SERVER="${{ gitea.server_url }}"
|
||||
cat > latest.json << ENDJSON
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"release_date": "$RELEASE_DATE",
|
||||
"download_urls": {
|
||||
"amd64": "${SERVER}/${REPO}/releases/download/${VERSION}/wgrplane-linux-amd64",
|
||||
"arm64": "${SERVER}/${REPO}/releases/download/${VERSION}/wgrplane-linux-arm64"
|
||||
}
|
||||
}
|
||||
ENDJSON
|
||||
|
||||
- name: Create Release and upload assets
|
||||
env:
|
||||
TOKEN: ${{ secrets.BUILD_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo "Value: [EMPTY]"
|
||||
exit 1
|
||||
else
|
||||
echo "Length: ${#TOKEN} characters"
|
||||
fi
|
||||
|
||||
REPO="${{ gitea.repository }}"
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
API="${{ gitea.server_url }}/api/v1"
|
||||
|
||||
# 0. Check & Delete Existing Release
|
||||
echo "=== 0. Check & Delete Existing Release ==="
|
||||
EXISTING_RESP=$(curl -s -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG")
|
||||
EXISTING_ID=$(echo "$EXISTING_RESP" | grep -o '"id":[0-9]*' | head -n 1 | cut -d':' -f2 || true)
|
||||
|
||||
if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then
|
||||
echo "⚠️ Found existing release for tag $TAG with ID: $EXISTING_ID. Deleting..."
|
||||
DELETE_RESP=$(curl -s -w "\n%{http_code}" -X DELETE -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/$EXISTING_ID")
|
||||
echo "✅ Delete response: $DELETE_RESP"
|
||||
else
|
||||
echo "No existing release found for $TAG. Proceeding..."
|
||||
fi
|
||||
|
||||
# 1. Create Release
|
||||
echo "=== 1. Create New Release ==="
|
||||
JSON_BODY=$(printf '{"tag_name":"%s","name":"%s","body":"Release %s","draft":false,"prerelease":%s}' "$TAG" "$TAG" "$TAG" "${{ inputs.prerelease }}")
|
||||
|
||||
RELEASE_RESP=$(curl -s -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$JSON_BODY" \
|
||||
"$API/repos/$REPO/releases")
|
||||
|
||||
# Ambil ID dengan lebih teliti
|
||||
# Tambahkan || true agar grep tidak membuat script crash (karena set -e) jika id tidak ditemukan
|
||||
RELEASE_ID=$(echo "$RELEASE_RESP" | grep -o '"id":[0-9]*' | head -n 1 | cut -d':' -f2 || true)
|
||||
|
||||
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
|
||||
echo "Gagal membuat release. Response: $RELEASE_RESP"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release ID: $RELEASE_ID"
|
||||
|
||||
# 2. Upload Assets
|
||||
for FILE in wgrplane-linux-amd64 wgrplane-linux-arm64 latest.json; do
|
||||
if [ -f "$FILE" ]; then
|
||||
echo "Uploading $FILE..."
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token $TOKEN" \
|
||||
-F "attachment=@$FILE" \
|
||||
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$FILE"
|
||||
else
|
||||
echo "Skip $FILE (tidak ditemukan)"
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Cleanup build artifacts
|
||||
if: always()
|
||||
run: |
|
||||
git config --global --remove-section http || true
|
||||
git config --global --unset-all core.askPass || true
|
||||
rm -f wgrplane-linux-amd64 wgrplane-linux-arm64 latest.json
|
||||
rm -rf app/frontend/node_modules app/frontend/dist
|
||||
echo "Cleanup done"
|
||||
@@ -0,0 +1,14 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]*.[0-9]*.[0-9]'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: "!contains(github.ref_name, 'beta') && !contains(github.ref_name, 'test')"
|
||||
uses: ./.gitea/workflows/deploy_call.yaml
|
||||
with:
|
||||
prerelease: false
|
||||
secrets: inherit
|
||||
@@ -1,3 +1,2 @@
|
||||
.test/
|
||||
wgrplane
|
||||
.sisyphus/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "app"]
|
||||
path = app
|
||||
url = https://git.datadunia.com/hainzero/WGRplane.git
|
||||
|
||||
@@ -1,27 +1,116 @@
|
||||
# WireGuard Policy Firewall (`03.wireguard-policy`)
|
||||
# AGENTS.md -- WireGuard Policy Firewall + WGRplane
|
||||
|
||||
## Architecture & Configuration Flow
|
||||
- **Goal:** Dynamic iptables/ipset rules based on WireGuard configuration (`wg0.conf`).
|
||||
- **Data Flow:** `wg0.conf` -> `wg-sync-policy.sh` -> `policy.json` -> `wg-policy-engine.sh` -> `iptables`/`ipset`
|
||||
- **File Watcher:** `wg-sync-watch.sh` monitors `wg0.conf` via `inotifywait` and debounces changes to re-run the sync and engine.
|
||||
**Generated:** 2026-05-08
|
||||
**Project:** 03.wireguard-policy
|
||||
|
||||
## Critical Parsing Rules & Design Constraints
|
||||
- **Target IPs Parsing (`#Access`):** The firewall script uses the custom `#Access` comment in `wg0.conf` to define egress/firewall whitelists for clients.
|
||||
- **Why `#Access` is mandatory:** WireGuard's native `AllowedIPs` on a Server dictates *routing* towards the client. If we put target destinations in the Server's `AllowedIPs`, the Server would wrongly route traffic destined for those IPs *into* the client tunnel. Therefore, a custom `#Access` comment is the only correct way to define firewall whitelist destinations without breaking WireGuard's Cryptokey Routing.
|
||||
- **Do not remove `#Access`:** Future agents MUST NOT attempt to refactor the script to parse targets from `AllowedIPs`. It is architecturally incorrect for this use case.
|
||||
## OVERVIEW
|
||||
Hybrid project: (1) WireGuard Policy Firewall -- shell script iptables/ipset engine driven by `wg0.conf` `#Access` comments. (2) WGRplane -- Go binary (port 10087) serving REST API + Vue 3 SPA for WireGuard control plane management.
|
||||
|
||||
## Testing & Verifying
|
||||
- `wg-policy-ctl status`: Check the overall health, including interface status, JSON validity, lock files, and iptables rules counts.
|
||||
- `wg-policy-ctl validate`: Validates `policy.json` without applying.
|
||||
- `wg-policy-ctl rules`: View the applied iptables rules in the active chain (`WG_POLICY`).
|
||||
- `wg-policy-ctl reload`: Forces a re-sync from `wg0.conf` and re-applies iptables.
|
||||
## STRUCTURE
|
||||
```
|
||||
03.wireguard-policy/
|
||||
├── app/ # WGRplane: Go backend + Vue 3 frontend (see app/AGENTS.md)
|
||||
│ ├── *.go # 13 Go files, flat package (no subdirs)
|
||||
│ ├── frontend/ # Vue 3 SPA (see app/frontend/src/AGENTS.md)
|
||||
│ └── docs/ # Swagger auto-generated (DO NOT EDIT)
|
||||
├── wg-sync-policy.sh # Parses wg0.conf → policy.json (atomic write + flock)
|
||||
├── wg-policy-engine.sh # Reads policy.json → iptables/ipset WG_POLICY chain
|
||||
├── wg-sync-watch.sh # inotifywait daemon, debounces wg0.conf changes
|
||||
├── wg-policy-lib.sh # Shared shell library (source only, never execute directly)
|
||||
├── wg-policy-ctl # CLI wrapper for operator use
|
||||
├── wg-policy-cleanup.sh # PostDown cleanup (run by WireGuard)
|
||||
├── *.service / *.timer # systemd units for daemon + health check
|
||||
├── install.sh # Unified installer (embeds all scripts, built by build.sh)
|
||||
├── build.sh / build.bat # Rebuilds install.sh from source scripts
|
||||
├── Dockerfile # Multi-stage: Go build + frontend build
|
||||
└── README.md # Full user documentation
|
||||
```
|
||||
|
||||
## Script Constraints & Gotchas
|
||||
- **Atomic Operations:** Always use atomic writes (`mv -f tmp target`) for `policy.json` to prevent the policy engine from reading partial files.
|
||||
- **Locking:** `wg-sync-policy.sh` uses file-based locking (`flock`) to prevent race conditions during updates.
|
||||
- **Rollback:** `wg-policy-engine.sh` creates a backup chain (`WG_POLICY_BAK`) and uses a trap on `ERR` to rollback if applying rules fails halfway.
|
||||
- **Dependencies:** Requires `jq` and `inotify-tools`.
|
||||
## WHERE TO LOOK
|
||||
| Task | Location |
|
||||
|------|----------|
|
||||
| Policy firewall logic | `wg-sync-policy.sh`, `wg-policy-engine.sh`, `wg-policy-lib.sh` |
|
||||
| Firewall rule chain | `wg-policy-engine.sh` -- WG_POLICY iptables chain |
|
||||
| Policy JSON schema | `wg-sync-policy.sh` output / `wg-policy-engine.sh` input |
|
||||
| WGRplane API handlers | `app/handlers.go` |
|
||||
| WGRplane DB models | `app/models.go` |
|
||||
| Auth middleware | `app/auth.go` (CAUTION: see known issues) |
|
||||
| nftables rules (forward mode) | `app/nftables.go` |
|
||||
| Webhook delivery | `app/webhook.go` |
|
||||
| Vue frontend | `app/frontend/src/` |
|
||||
| i18n translations | `app/frontend/src/i18n/locales/` (en, id, zh) |
|
||||
| Backend i18n | `app/active.{en,id,zh}.json` |
|
||||
| Systemd service config | `wg-policy.service`, `wgrplane.service` |
|
||||
|
||||
## Development Commands
|
||||
- Restart the watcher service: `systemctl restart wg-policy.service`
|
||||
- Check service logs: `journalctl -u wg-policy.service -f`
|
||||
## CRITICAL DESIGN RULES
|
||||
|
||||
### Policy Firewall -- `#Access` MUST NOT be changed
|
||||
- WireGuard `AllowedIPs` on the server side = Cryptokey Routing, not firewall whitelist
|
||||
- Putting destination IPs in server's `AllowedIPs` breaks routing (WG tunnels those packets INTO client)
|
||||
- `#Access` comment is the ONLY correct way to declare firewall destinations per-peer
|
||||
- **NEVER refactor to parse targets from `AllowedIPs`** -- architecturally incorrect
|
||||
|
||||
### Atomic Writes
|
||||
- Always `mv -f tmp target` for `policy.json` -- never write directly
|
||||
- `wg-sync-policy.sh` uses `flock` -- never bypass locking
|
||||
|
||||
### Rollback
|
||||
- `wg-policy-engine.sh` creates `WG_POLICY_BAK` chain; traps `ERR` for rollback
|
||||
|
||||
### No SaveConfig
|
||||
- WireGuard `SaveConfig = true` strips ALL comments including `#Access` -- NEVER enable
|
||||
|
||||
## KNOWN ISSUES / GOTCHAS
|
||||
- **AuthMiddleware NOT applied**: `auth.go` defines `AuthMiddleware` but it is NOT wired to any routes in `main.go`. All API endpoints currently unprotected (auth header still checked inside handlers via manual if-check, but middleware chain is absent).
|
||||
- **TOTP secrets in-memory**: `totpSecrets` map in `auth.go` is not persisted; lost on restart.
|
||||
- **WebSocket stats are MOCK**: `stats.go` broadcasts randomly generated numbers, not real WireGuard traffic.
|
||||
- **Plugin system is stub**: `plugins.go` TelegramNotifier/SlackNotifier just print to stdout.
|
||||
- **Binary + DB in app/**: `wgrplane` binary and `wgrplane.db` live in `app/` (non-standard, intentional).
|
||||
- **Duplicate i18n**: `app/active.*.json` (backend i18n) and `app/frontend/src/i18n/locales/` (frontend i18n) are separate systems.
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Never parse firewall targets from `AllowedIPs` -- use `#Access` only
|
||||
- Never write `policy.json` without atomic mv + flock
|
||||
- Never run `wg-policy-lib.sh` directly (source-only library)
|
||||
- Never enable `SaveConfig = true` in wg0.conf
|
||||
- Do NOT edit `app/docs/docs.go` -- auto-generated by swaggo
|
||||
- Do NOT put business logic in `app/main.go` -- it's bootstrap only
|
||||
|
||||
## COMMANDS
|
||||
|
||||
### Policy Firewall
|
||||
```bash
|
||||
wg-policy-ctl status # Health: interface, JSON validity, rule counts
|
||||
wg-policy-ctl validate # Validate policy.json without applying
|
||||
wg-policy-ctl rules # Show active WG_POLICY iptables rules
|
||||
wg-policy-ctl reload # Force re-sync from wg0.conf + re-apply
|
||||
wg-policy-ctl policy # Show raw policy.json
|
||||
wg-policy-ctl log # Show dropped packet logs (rate-limited)
|
||||
systemctl restart wg-policy.service
|
||||
journalctl -u wg-policy.service -f
|
||||
```
|
||||
|
||||
### WGRplane (Go App)
|
||||
```bash
|
||||
cd app && go build -o ../wgrplane . # Build Go binary
|
||||
cd app/frontend && npm install && npm run build # Build Vue frontend
|
||||
./wgrplane # Run (serves :10087)
|
||||
systemctl restart wgrplane.service
|
||||
journalctl -u wgrplane.service -f
|
||||
curl -H "wg-rplane-datadunia: test-api-key" http://localhost:10087/api/servers
|
||||
```
|
||||
|
||||
### Installer
|
||||
```bash
|
||||
./build.sh # Rebuild install.sh from source scripts (Linux)
|
||||
./build.bat # Rebuild install.sh from source scripts (Windows)
|
||||
sudo ./install.sh install # Full install (deps + scripts + systemd + service)
|
||||
sudo ./install.sh uninstall # Remove all
|
||||
```
|
||||
|
||||
## DEPENDENCIES
|
||||
- Shell: `jq`, `inotify-tools` (required); `ipset` (optional, O(1) lookup)
|
||||
- Go: 1.25.1, SQLite (glebarez/sqlite), Gorilla Mux, GORM, JWT, TOTP, WebSocket
|
||||
- Frontend: Vue 3, TypeScript, Vite, TailwindCSS 4, vue-i18n 9
|
||||
- Auth headers: `wg-rplane-datadunia: <KEY>` (API key) | `Authorization: Bearer <JWT>` | `X-TOTP: <CODE>`
|
||||
- Env vars: `WG_API_KEY` (default: test-api-key), `JWT_SECRET`, `WG_RPLANE_MODE` (forward|standalone), `APP_FRONTEND_DIR`
|
||||
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
## Multi-stage Dockerfile for WGRplane
|
||||
## Stage 1: Go backend build
|
||||
FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder-go
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
go mod download
|
||||
COPY app ./app
|
||||
ENV CGO_ENABLED=0
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
|
||||
go build -o /bin/wgrplane ./app
|
||||
|
||||
## Stage 2: Frontend build (Vue 3)
|
||||
FROM node:20-alpine AS frontend-builder
|
||||
WORKDIR /src/app/frontend
|
||||
COPY app/frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY app/frontend/ .
|
||||
RUN npm run build
|
||||
|
||||
## Stage 3: Production image
|
||||
FROM alpine:latest
|
||||
RUN apk --no-cache add ca-certificates
|
||||
WORKDIR /root
|
||||
COPY --from=builder-go /bin/wgrplane /usr/local/bin/wgrplane
|
||||
COPY --from=frontend-builder /src/app/frontend/dist /var/www/frontend
|
||||
ENV APP_FRONTEND_DIR=/var/www/frontend
|
||||
EXPOSE 10087
|
||||
ENTRYPOINT ["/usr/local/bin/wgrplane"]
|
||||
@@ -1,139 +1,351 @@
|
||||
# WGRplane
|
||||
# WireGuard Policy Firewall + WGRplane Control Plane
|
||||
|
||||
**WireGuard Control Plane with Dynamic Policy Firewall.**
|
||||
A complete WireGuard management solution combining two powerful components:
|
||||
|
||||
WGRplane is a Go-native WireGuard management dashboard paired with a shell-based dynamic iptables/ipset policy engine. It gives you full peer lifecycle management, per-peer firewall policies, webhook integrations, and a glassmorphism-styled Vue 3 frontend.
|
||||
1. **WireGuard Dynamic Policy Firewall** - A lightweight, robust iptables/ipset policy engine that restricts and controls WireGuard peer traffic directly from `wg0.conf` using custom `#Access` comments.
|
||||
2. **WGRplane** - A Go-native control plane application with Vue 3 frontend, providing a modern web dashboard for WireGuard management with real-time monitoring, peer CRUD, and hybrid firewall enforcement.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## 📑 Table of Contents
|
||||
|
||||
WGRplane operates in two complementary layers:
|
||||
### Policy Firewall (Shell Scripts)
|
||||
- [Architecture & Data Flow](#-architecture--data-flow)
|
||||
- [Installation](#-installation)
|
||||
- [Prerequisites](#-prerequisites)
|
||||
- [wg0.conf Integration](#-integrasi-ke-wg0conf)
|
||||
- [CLI Usage](#-wg-policy-ctl-cli-usage)
|
||||
- [Systemd Integration](#-systemd-integration-watcher-daemon)
|
||||
|
||||
| Layer | What it does |
|
||||
|-------|-------------|
|
||||
| **WGRplane (Go + Vue 3)** | Web dashboard and REST API for managing WireGuard servers, peers, webhooks, SMTP, and scheduling. |
|
||||
| **Policy Firewall (Shell)** | Dynamic iptables/ipset engine that reads `#Access` comments from `wg0.conf` to enforce per-peer egress rules. |
|
||||
### WGRplane (Go App)
|
||||
- [WGRplane Overview](#-wgrplane-overview)
|
||||
- [WGRplane Architecture](#-wgrplane-architecture)
|
||||
- [WGRplane Features](#-wgrplane-features)
|
||||
- [WGRplane Tech Stack](#-wgrplane-tech-stack)
|
||||
- [WGRplane API Endpoints](#-wgrplane-api-endpoints)
|
||||
- [WGRplane Installation](#-wgrplane-installation)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Architecture & Data Flow
|
||||
|
||||
1. **`wg0.conf`**: The standard WireGuard configuration. Contains standard `[Peer]` configs alongside a custom `#Access` tag.
|
||||
2. **`wg-sync-policy.sh`**: Safely parses `wg0.conf` and generates a structured `/etc/wireguard/policy.json` atomically.
|
||||
3. **`wg-policy-engine.sh`**: Reads `policy.json` to generate robust rules, applying `iptables` and `ipset` directly to the system.
|
||||
4. **Watcher Daemon**: Monitors `wg0.conf` for changes via `inotifywait` and triggers the pipeline seamlessly when updates are made.
|
||||
|
||||
---
|
||||
|
||||
## 📁 Installation
|
||||
|
||||
The easiest way to install is using the provided `install.sh` script, which automatically installs dependencies, copies all scripts to `/usr/local/bin/`, sets up the systemd daemon, and enables the service.
|
||||
|
||||
```bash
|
||||
# Install everything
|
||||
sudo ./install.sh install
|
||||
|
||||
# Uninstall everything
|
||||
sudo ./install.sh uninstall
|
||||
```
|
||||
|
||||
### Building the Installer (For Developers)
|
||||
|
||||
If you modify any of the source `.sh` or `.service` files, you must rebuild the `install.sh` script using the provided builders.
|
||||
|
||||
**On Linux (Bash):**
|
||||
```bash
|
||||
./build.sh
|
||||
```
|
||||
|
||||
**On Windows (CMD/PowerShell):**
|
||||
```cmd
|
||||
build.bat
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📦 Prerequisites
|
||||
|
||||
| Package | Required | Install |
|
||||
|---------|----------|---------|
|
||||
| `jq` | **Yes** | `apt install jq` |
|
||||
| `inotify-tools` | **Yes** (for watcher daemon) | `apt install inotify-tools` |
|
||||
| `ipset` | Optional | `apt install ipset` |
|
||||
|
||||
If `ipset` is not installed, the engine will automatically fall back to per-rule `iptables` whitelist entries. This works fine for small deployments. For large numbers of clients/targets, `ipset` is recommended for O(1) lookup performance.
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Integrasi ke `wg0.conf`
|
||||
|
||||
To integrate the engine, you need to append hooks into your `wg0.conf` interface block, and declare the `#Access` tags under each peer.
|
||||
|
||||
### 1. Interface Block (Hooks)
|
||||
Add the `PostUp` and `PostDown` scripts so the engine initializes correctly during VPN startup and removes traces upon shutdown.
|
||||
|
||||
> **⚠️ WireGuard does NOT support multiline values.** Every command must be on a `PostUp = ...` or `PostDown = ...` line. Bare commands without the `PostUp =` prefix will cause `Configuration parsing error`.
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.0.0.1/24
|
||||
ListenPort = 51820
|
||||
PrivateKey = <SERVER_PRIVATE_KEY>
|
||||
|
||||
# Policy engine: auto-handles NAT, IP forwarding, and dynamic firewall rules
|
||||
PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh
|
||||
|
||||
# Policy engine: cleanup all firewall and routing traces
|
||||
PostDown = /usr/local/bin/wg-policy-cleanup.sh
|
||||
```
|
||||
|
||||
### 2. Peer Block (`#Access` Tags)
|
||||
For each client, use the `#Access` comment line. Define the destinations (targets) the peer is allowed to access. You can separate multiple IPs or CIDRs with commas or semicolons.
|
||||
|
||||
> **⚠️ WARNING: Do NOT use `SaveConfig = true`!**
|
||||
> WireGuard's `SaveConfig` feature overwrites `wg0.conf` directly and **strips all comments**, which will permanently delete all `#Access` tags. If you are using a Web UI/Dashboard, make sure it does not strip unknown comments when saving.
|
||||
|
||||
```ini
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_1_PUBKEY>
|
||||
AllowedIPs = 10.0.0.2/32
|
||||
#Access 192.168.1.10/32, 192.168.12.0/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_2_PUBKEY>
|
||||
AllowedIPs = 10.0.0.3/32
|
||||
#Access = 10.0.0.1/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_3_PUBKEY>
|
||||
AllowedIPs = 10.0.0.4/32
|
||||
#Access
|
||||
# ^ (Empty Access implies internet-only, client isolation applies)
|
||||
```
|
||||
|
||||
**⚠️ Important constraint:** Why `#Access` instead of just using `AllowedIPs` directly?
|
||||
WireGuard uses `AllowedIPs` for Cryptokey Routing (deciding which tunnel interface to route outbound packets). If you put an internal server IP inside the server's `wg0.conf` AllowedIPs block, WireGuard will aggressively capture and redirect packets bound for that internal server into the VPN client's tunnel. The `#Access` comment separates routing parameters from firewall parameters cleanly.
|
||||
|
||||
---
|
||||
|
||||
## 🛠 `wg-policy-ctl` CLI Usage
|
||||
|
||||
You don't need to manually interact with `iptables` or `.json` files. Use the `wg-policy-ctl` wrapper.
|
||||
|
||||
```bash
|
||||
# View the health of the firewall engine and active locks
|
||||
wg-policy-ctl status
|
||||
|
||||
# View the raw, parsed JSON policy
|
||||
wg-policy-ctl policy
|
||||
|
||||
# Inspect active iptables rules
|
||||
wg-policy-ctl rules
|
||||
|
||||
# Check memory sets mapping IP targets (ipset)
|
||||
wg-policy-ctl ipset
|
||||
|
||||
# Manually re-sync rules immediately
|
||||
wg-policy-ctl reload
|
||||
|
||||
# Inspect dropped packets (rate-limited log output)
|
||||
wg-policy-ctl log
|
||||
|
||||
# See connection statistics, how many targets loaded
|
||||
wg-policy-ctl stats
|
||||
|
||||
# Force validation of the policy schema
|
||||
wg-policy-ctl validate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Systemd Integration (Watcher Daemon)
|
||||
|
||||
If you use the `install.sh` script, the daemon is automatically installed, enabled, and started for you. It monitors `wg0.conf` for changes and triggers the pipeline seamlessly.
|
||||
|
||||
### Manual File Installation (If not using install.sh)
|
||||
Place the three provided systemd unit files into `/etc/systemd/system/`.
|
||||
|
||||
| Systemd File | Location | Description |
|
||||
|--------------|----------|-------------|
|
||||
| `wg-policy.service` | `/etc/systemd/system/wg-policy.service` | The main daemon that runs `wg-sync-watch.sh` |
|
||||
| `wg-policy-health.timer` | `/etc/systemd/system/wg-policy-health.timer` | Triggers the health check every 5 minutes |
|
||||
| `wg-policy-health.service`| `/etc/systemd/system/wg-policy-health.service`| Executes the actual health check logic |
|
||||
|
||||
```bash
|
||||
# Example copy command
|
||||
cp wg-policy.service wg-policy-health.timer wg-policy-health.service /etc/systemd/system/
|
||||
```
|
||||
|
||||
### 2. Enable & Start Services
|
||||
After copying the files, reload systemd to recognize them, then enable and start the services.
|
||||
|
||||
```bash
|
||||
systemctl daemon-reload
|
||||
systemctl enable wg-policy.service
|
||||
systemctl enable wg-policy-health.timer
|
||||
systemctl start wg-policy.service
|
||||
systemctl start wg-policy-health.timer
|
||||
```
|
||||
|
||||
Check the watcher logs:
|
||||
```bash
|
||||
journalctl -u wg-policy.service -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚀 WGRplane Overview
|
||||
|
||||
**WGRplane** is a Go-native WireGuard control plane application with a Vue 3 frontend, providing a modern web dashboard for WireGuard management. It features a single Go binary backend, SPA frontend, dynamic policy firewall integration, and glassmorphism UI design.
|
||||
|
||||
The application lives in the `/app` directory. For full documentation, see [`app/README.md`](app/README.md).
|
||||
|
||||
---
|
||||
|
||||
## 🏗 WGRplane Architecture
|
||||
|
||||
```
|
||||
wg0.conf (with/without #Access)
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ WGRplane (Go Binary :10087) │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Gorilla │ │ GORM │ │ nftables │ │
|
||||
│ │ Mux Router│ │ SQLite │ │ Engine │ │
|
||||
│ └───────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Webhook │ │ Scheduler│ │ WebSocket│ │
|
||||
│ │ Engine │ │ Cron │ │ Hub │ │
|
||||
│ └───────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Auth │ │ SMTP │ │ Plugins │ │
|
||||
│ │ JWT/TOTP │ │ Email │ │ TG/Slack │ │
|
||||
│ └───────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓ HTTP/WebSocket
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Frontend (Vue 3 + TypeScript + Tailwind) │
|
||||
│ Glassmorphism UI, i18n, Dark/Light mode │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Hybrid Mode
|
||||
|
||||
The Go backend supports two server modes:
|
||||
WGRplane supports two server modes:
|
||||
|
||||
- **`forward`** -- The WGRplane instance directly applies nftables rules on the local machine. Peer access policies are enforced immediately via `nft` commands.
|
||||
- **`standalone`** -- The instance acts as a control plane that triggers webhooks to remote WireGuard servers. Policy enforcement happens on the remote side.
|
||||
- **`forward`** - Directly applies nftables rules on the local machine. Peer policies are enforced via `nft` commands.
|
||||
- **`standalone`** - Acts as a control plane that triggers webhooks to remote WireGuard servers. Policy enforcement happens on the remote side.
|
||||
|
||||
### 2-Column Policy
|
||||
|
||||
Each peer has two independent policy columns:
|
||||
|
||||
| Column | Purpose |
|
||||
|--------|---------|
|
||||
| **AllowAccess** | List of CIDRs the peer can reach (internal targets). |
|
||||
| **AllowInternet** | Boolean flag. When `true`, the peer gets unrestricted internet egress. |
|
||||
| Column | Function |
|
||||
|--------|----------|
|
||||
| **AllowAccess** | List of CIDRs the peer can access (internal targets) |
|
||||
| **AllowInternet** | Boolean flag. If `true`, peer gets unlimited internet access (MASQUERADE) |
|
||||
|
||||
Peers with neither rule are isolated from each other and from the internet by default.
|
||||
Peers without any rules are isolated from other peers and the internet by default.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
## ✨ WGRplane Features
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "WGRplane Dashboard"
|
||||
A[Vue 3 Frontend] -->|HTTP/WebSocket| B[Go REST API :10087]
|
||||
end
|
||||
- **Go-Native Architecture**: Single Go binary handles all API, database, webhooks, scheduler, and nftables. No Python/Flask needed.
|
||||
- **Complete Peer CRUD**: Add, edit, delete peers. Generate QR codes for mobile client import. Export `.conf` configuration files.
|
||||
- **Hybrid Mode**: `forward` mode (local nftables) or `standalone` mode (webhook to remote servers).
|
||||
- **2-Column Policy UI**: "Allow Access" column (firewall whitelist CIDR) and "Allow Internet" toggle per peer.
|
||||
- **Real-time Monitoring**: WebSocket broadcasts peer statistics and traffic every 5 seconds.
|
||||
- **Automated Scheduling**: Daily cron jobs to delete expired peers, restrict over-limit peers, and reset monthly data usage.
|
||||
- **Security**: API Key authentication (`wg-rplane-datadunia`), JWT Bearer tokens, and TOTP (2FA).
|
||||
- **Webhook Engine**: Integration with remote servers (Mikrotik, etc.). Retry with exponential backoff, custom headers, Go templates.
|
||||
- **Plugin System**: Telegram, Slack, and Traffic Logger notifications.
|
||||
- **i18n & Themes**: Multi-language (English, Indonesian, Chinese). Dark/Light/Auto mode.
|
||||
- **Glassmorphism UI**: Futuristic design with frosted glass cards, buttons, and inputs.
|
||||
|
||||
subgraph "Go Backend"
|
||||
B --> C[(SQLite DB)]
|
||||
B --> D[Webhook Engine]
|
||||
B --> E[Scheduler Cron]
|
||||
B --> F[nftables Engine]
|
||||
B --> G[SMTP / Email]
|
||||
B --> H[Plugin Manager]
|
||||
end
|
||||
---
|
||||
|
||||
subgraph "Policy Firewall"
|
||||
I[wg0.conf #Access] -->|inotifywait| J[wg-sync-watch.sh]
|
||||
J --> K[wg-sync-policy.sh]
|
||||
K -->|atomic write| L[policy.json]
|
||||
L --> M[wg-policy-engine.sh]
|
||||
M --> N[iptables / ipset]
|
||||
end
|
||||
|
||||
D -.->|peer_created/updated/deleted| O[External Webhooks]
|
||||
F -.->|forward mode| N
|
||||
E -.->|daily/monthly| P[Peer Lifecycle]
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
wg0.conf (#Access comments)
|
||||
↓ inotifywait detects change
|
||||
wg-sync-watch.sh (debounced trigger)
|
||||
↓
|
||||
wg-sync-policy.sh (parses wg0.conf → policy.json, atomic write)
|
||||
↓
|
||||
wg-policy-engine.sh (reads policy.json → iptables/ipset rules)
|
||||
↓
|
||||
Active firewall rules (WG_POLICY chain)
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
## 🛠 WGRplane Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| **Backend** | Go 1.25, Gorilla Mux, GORM (SQLite) |
|
||||
| **Backend** | Go, Gorilla Mux, GORM (SQLite via glebarez/sqlite) |
|
||||
| **Frontend** | Vue 3, TypeScript, Vite, TailwindCSS 4, vue-i18n 9 |
|
||||
| **Auth** | JWT (golang-jwt), TOTP (pquerna/otp), API Key |
|
||||
| **Auth** | JWT (golang-jwt/v5), TOTP (pquerna/otp), API Key |
|
||||
| **WebSockets** | gorilla/websocket |
|
||||
| **Webhooks** | Go net/http with retry + exponential backoff |
|
||||
| **Scheduling** | robfig/cron v3 |
|
||||
| **QR Code** | skip2/go-qrcode |
|
||||
| **Email** | jordan-wright/email (SMTP) |
|
||||
| **Firewall** | Bash, iptables, ipset, nftables, inotify-tools, jq |
|
||||
| **Container** | Docker (multi-stage build), docker-compose |
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## 🌐 WGRplane API Endpoints Summary
|
||||
|
||||
All endpoints are served on port **10087**. For complete API documentation with request/response details, see [`app/README.md`](app/README.md) or visit `/swagger/` on your running instance.
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Header | Notes |
|
||||
|--------|--------|-------|
|
||||
| API Key | `wg-rplane-datadunia: <KEY>` | Set via env var `WG_API_KEY`. Default: `test-api-key` |
|
||||
| JWT | `Authorization: Bearer <TOKEN>` | Expires in 15 minutes. Secret via env var `JWT_SECRET` |
|
||||
| TOTP | `X-TOTP: <CODE>` | Required if user enables TOTP |
|
||||
|
||||
### Main Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/servers` | `GET/POST` | List all servers / Create new server |
|
||||
| `/api/servers/{id}` | `GET/PUT/DELETE` | Get/Update/Delete server |
|
||||
| `/api/servers/{id}/peers` | `GET/POST` | List peers / Create new peer |
|
||||
| `/api/peers/{id}` | `PUT/DELETE` | Update/Delete peer |
|
||||
| `/api/peers/{id}/config` | `GET` | Download WireGuard `.conf` file |
|
||||
| `/api/peers/{id}/qrcode` | `GET` | Generate QR code PNG for mobile import |
|
||||
| `/api/servers/{id}/webhooks` | `GET/POST` | List/Create webhooks |
|
||||
| `/api/stats` | `GET` | Global statistics |
|
||||
| `/ws/stats` | WebSocket | Real-time stats broadcast (5s interval) |
|
||||
| `/swagger/` | - | Interactive Swagger UI documentation |
|
||||
|
||||
---
|
||||
|
||||
## 📦 WGRplane Installation
|
||||
|
||||
### Option 1: Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
# Clone repository
|
||||
git clone https://git.datadunia.com/hainzero/WGRplane.git
|
||||
cd 03.wireguard-policy
|
||||
|
||||
# Start both WGRplane and WireGuard
|
||||
# Start WGRplane and WireGuard
|
||||
docker compose up -d
|
||||
|
||||
# Access the dashboard
|
||||
# http://localhost:10087
|
||||
# Access dashboard at http://localhost:10087
|
||||
```
|
||||
|
||||
The compose stack runs:
|
||||
- **WGRplane** on port `10087` (Go API + Vue frontend)
|
||||
- **WireGuard** container with host networking for kernel module access
|
||||
|
||||
### Option 2: Install Script
|
||||
|
||||
```bash
|
||||
# Run the automated installer (Ubuntu/Debian/CentOS)
|
||||
# Run automated installer (Ubuntu/Debian/CentOS)
|
||||
sudo ./install.sh install
|
||||
|
||||
# Uninstall
|
||||
sudo ./install.sh uninstall
|
||||
```
|
||||
|
||||
The install script handles Docker installation, repository cloning, `.env` creation, and service startup.
|
||||
|
||||
### Option 3: Manual Build
|
||||
|
||||
```bash
|
||||
# Build the Go binary
|
||||
# Build Go binary
|
||||
cd app
|
||||
go build -o ../wgrplane .
|
||||
cd ..
|
||||
|
||||
# Build the frontend
|
||||
# Build frontend
|
||||
cd app/frontend
|
||||
npm install && npm run build
|
||||
cd ../..
|
||||
@@ -143,333 +355,64 @@ cd ../..
|
||||
# Server starts on :10087
|
||||
```
|
||||
|
||||
### Option 4: Systemd Service
|
||||
### Option 4: Native CLI / Systemd Service
|
||||
|
||||
The WGRplane binary includes a built-in CLI to manage its own systemd service.
|
||||
|
||||
```bash
|
||||
# Install the service file
|
||||
sudo cp wgrplane.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wgrplane.service
|
||||
sudo systemctl start wgrplane.service
|
||||
# Check system dependencies first
|
||||
./wgrplane doctor
|
||||
|
||||
# Install as systemd service (auto-creates unit, enables, and starts)
|
||||
sudo ./wgrplane install
|
||||
|
||||
# View logs
|
||||
journalctl -u wgrplane.service -f
|
||||
journalctl -u wgrplane -f
|
||||
|
||||
# Other available commands:
|
||||
sudo ./wgrplane stop
|
||||
sudo ./wgrplane restart
|
||||
sudo ./wgrplane uninstall
|
||||
```
|
||||
|
||||
---
|
||||
### Manual Serve & Config
|
||||
|
||||
## API Documentation
|
||||
|
||||
All API endpoints are served on port `10087`. Authentication is via API key header (`wg-rplane-datadunia`) or JWT Bearer token with optional TOTP.
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Header | Notes |
|
||||
|--------|--------|-------|
|
||||
| API Key | `wg-rplane-datadunia: <KEY>` | Set via `WG_API_KEY` env var. Default: `test-api-key`. |
|
||||
| JWT | `Authorization: Bearer <TOKEN>` | 15-minute expiry. Set secret via `JWT_SECRET` env var. |
|
||||
| TOTP | `X-TOTP: <CODE>` | Required if user has TOTP enabled. |
|
||||
|
||||
### Servers
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/servers` | `GET` | List all registered WireGuard servers. |
|
||||
| `/api/servers` | `POST` | Create a new server entry. Body: `{name, mode, publicKey, endpoint}`. |
|
||||
| `/api/servers/{id}` | `GET` | Get a single server by ID. |
|
||||
| `/api/servers/{id}` | `PUT` | Update server fields (name, mode, publicKey, endpoint). |
|
||||
| `/api/servers/{id}` | `DELETE` | Delete a server and cascade-remove its peers and webhooks. |
|
||||
|
||||
### Peers
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/servers/{id}/peers` | `GET` | List all peers for a server. |
|
||||
| `/api/servers/{id}/peers` | `POST` | Create a new peer. Body: `{publicKey, ip, allowAccess, allowInternet}`. Applies nftables rules in `forward` mode or triggers webhooks in `standalone` mode. |
|
||||
| `/api/peers/{id}` | `PUT` | Update a peer. Computes diffs and applies incremental nftables changes. |
|
||||
| `/api/peers/{id}` | `DELETE` | Delete a peer. Cleans up nftables rules and triggers webhooks. |
|
||||
| `/api/peers/{id}/config` | `GET` | Download the peer's WireGuard `.conf` file. |
|
||||
| `/api/peers/{id}/qrcode` | `GET` | Get a QR code PNG image of the peer config (for mobile import). |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/servers/{id}/webhooks` | `GET` | List webhooks for a server. |
|
||||
| `/api/servers/{id}/webhooks` | `POST` | Create a webhook. Body: `{name, url, template, customBody, customHeaders, subscribedActions, isEnabled, verifySSL}`. |
|
||||
| `/api/webhooks/{id}` | `DELETE` | Delete a webhook. |
|
||||
|
||||
### SMTP Settings
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/settings/smtp` | `GET` | Get current SMTP configuration. |
|
||||
| `/api/settings/smtp` | `POST` | Save SMTP settings for email notifications. |
|
||||
|
||||
### Statistics
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/api/stats` | `GET` | Global stats: total servers, peers, webhooks. |
|
||||
| `/api/servers/{id}/stats` | `GET` | Per-server stats: peer count, webhook count, server details. |
|
||||
|
||||
### WebSocket
|
||||
|
||||
| Endpoint | Protocol | Description |
|
||||
|----------|----------|-------------|
|
||||
| `/ws/stats` | WebSocket | Real-time stats broadcast (5-second interval). Connects to the Hub for live peer/traffic updates. |
|
||||
|
||||
---
|
||||
|
||||
## Webhook Payload Spec
|
||||
|
||||
Webhooks are triggered on peer lifecycle events (`peer_created`, `peer_updated`, `peer_deleted`, `policy_changed`). The engine supports three template modes: `default` (raw JSON), `mikrotik` (RouterOS-formatted), and `custom` (Go template).
|
||||
|
||||
### Default Payload
|
||||
|
||||
```json
|
||||
{
|
||||
"event": "peer_created",
|
||||
"timestamp": "2026-05-03T10:30:00Z",
|
||||
"server": {
|
||||
"id": 1,
|
||||
"name": "wg-server-01",
|
||||
"mode": "forward",
|
||||
"publicKey": "abc123...",
|
||||
"endpoint": "vpn.example.com:51820"
|
||||
},
|
||||
"peer": {
|
||||
"id": 2,
|
||||
"publicKey": "xyz789...",
|
||||
"ip": "10.0.0.2",
|
||||
"allowAccess": ["192.168.1.0/24", "10.10.0.0/16"],
|
||||
"allowInternet": true,
|
||||
"enabled": true,
|
||||
"dataLimitGB": 0,
|
||||
"expiresAt": "0001-01-01T00:00:00Z"
|
||||
},
|
||||
"policy": {
|
||||
"action": "created",
|
||||
"changes": ["peer_created"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mikrotik Template
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "created",
|
||||
"peer": {
|
||||
"public_key": "xyz789...",
|
||||
"ip": "10.0.0.2",
|
||||
"allow_access": "[\"192.168.1.0/24\"]",
|
||||
"allow_internet": true
|
||||
},
|
||||
"server": {
|
||||
"name": "wg-server-01",
|
||||
"mode": "forward"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Webhook Features
|
||||
|
||||
- **Retry with backoff**: Failed deliveries retry up to 3 times with exponential backoff (2s, 4s, 8s).
|
||||
- **SSL verification**: Toggleable per-webhook via `verifySSL`.
|
||||
- **Custom headers**: Per-webhook header injection via `customHeaders` JSON.
|
||||
- **Global webhooks**: Set `isGlobal: true` to fire across all servers.
|
||||
- **Action filtering**: Subscribe to specific events via `subscribedActions` array.
|
||||
|
||||
---
|
||||
|
||||
## UI Features
|
||||
|
||||
The frontend is a Vue 3 + TypeScript SPA with a glassmorphism design language.
|
||||
|
||||
### Design System
|
||||
|
||||
- **Glassmorphism**: Frosted glass cards, buttons, and inputs with backdrop blur effects.
|
||||
- **TailwindCSS 4**: Utility-first styling with full dark mode support via `dark:` variants.
|
||||
- **Responsive**: Mobile-first layout that adapts to all screen sizes.
|
||||
|
||||
### Features
|
||||
|
||||
| Feature | Description |
|
||||
|---------|-------------|
|
||||
| **Multi-language (i18n)** | English, Indonesian, and Chinese via vue-i18n 9. Locale auto-detected from browser. |
|
||||
| **Theme Switching** | Dark / Light / Auto (follows system preference) via `@vueuse/core`. |
|
||||
| **Real-time Stats** | WebSocket connection broadcasts live peer counts and traffic data every 5 seconds. |
|
||||
| **Charts** | Traffic visualization via Chart.js + vue-chartjs. |
|
||||
| **Toast Notifications** | Non-intrusive alerts via vue-sonner. |
|
||||
| **QR Code Import** | Generate scannable QR codes for quick mobile WireGuard client setup. |
|
||||
|
||||
### Frontend Structure
|
||||
|
||||
```
|
||||
app/frontend/src/
|
||||
├── App.vue # Root component with theme/i18n providers
|
||||
├── main.ts # App bootstrap (Vue, Router, i18n)
|
||||
├── router/ # Vue Router definitions
|
||||
├── i18n/ # Locale files (en.json, id.json, zh.json)
|
||||
├── components/ # Glass UI components (Card, Button, Input, Toggle)
|
||||
├── composables/ # Vue composables (useTheme, etc.)
|
||||
├── views/ # Page components (Dashboard, Servers, Peers, Settings)
|
||||
└── types/ # TypeScript type definitions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Policy Firewall (`#Access`)
|
||||
|
||||
The shell-based policy engine enforces per-peer firewall rules directly from `wg0.conf`.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Add `#Access` comments under each `[Peer]` block in `wg0.conf`.
|
||||
2. The watcher daemon (`wg-sync-watch.sh`) detects file changes via `inotifywait`.
|
||||
3. `wg-sync-policy.sh` parses the config and writes `policy.json` atomically.
|
||||
4. `wg-policy-engine.sh` reads the JSON and applies iptables/ipset rules.
|
||||
|
||||
### Example `wg0.conf`
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.0.0.1/24
|
||||
ListenPort = 51820
|
||||
PrivateKey = <SERVER_PRIVATE_KEY>
|
||||
|
||||
PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh
|
||||
PostDown = /usr/local/bin/wg-policy-cleanup.sh
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_1_PUBKEY>
|
||||
AllowedIPs = 10.0.0.2/32
|
||||
#Access 192.168.1.10/32, 192.168.12.0/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_2_PUBKEY>
|
||||
AllowedIPs = 10.0.0.3/32
|
||||
#Access 10.0.0.1/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_3_PUBKEY>
|
||||
AllowedIPs = 10.0.0.4/32
|
||||
#Access
|
||||
# Empty Access = internet-only, client isolation applies
|
||||
```
|
||||
|
||||
### Why `#Access` Instead of `AllowedIPs`?
|
||||
|
||||
WireGuard uses `AllowedIPs` for Cryptokey Routing. Putting destination IPs in the server's `AllowedIPs` would cause WireGuard to route traffic for those IPs into the client tunnel. The `#Access` comment cleanly separates firewall policy from routing configuration.
|
||||
|
||||
### CLI: `wg-policy-ctl`
|
||||
To run the server manually in the foreground with custom ports:
|
||||
|
||||
```bash
|
||||
wg-policy-ctl status # Health check, lock status, rule counts
|
||||
wg-policy-ctl policy # View raw policy.json
|
||||
wg-policy-ctl rules # Inspect active iptables rules
|
||||
wg-policy-ctl ipset # View ipset mappings
|
||||
wg-policy-ctl reload # Force re-sync and re-apply
|
||||
wg-policy-ctl log # View dropped packet logs
|
||||
wg-policy-ctl stats # Connection statistics
|
||||
wg-policy-ctl validate # Validate policy.json schema
|
||||
./wgrplane serve --port 8080 --host 127.0.0.1
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Package | Required | Install |
|
||||
|---------|----------|---------|
|
||||
| `jq` | Yes | `apt install jq` |
|
||||
| `inotify-tools` | Yes (watcher daemon) | `apt install inotify-tools` |
|
||||
| `ipset` | Optional | `apt install ipset` |
|
||||
|
||||
Without `ipset`, the engine falls back to per-rule iptables entries. This works for small deployments. For large peer counts, `ipset` provides O(1) lookup performance.
|
||||
On first run, it generates a default configuration file at `~/.config/wgrplane/config.json`.
|
||||
|
||||
---
|
||||
|
||||
## Scheduler
|
||||
|
||||
The Go backend runs three background cron jobs:
|
||||
|
||||
| Schedule | Job | Action |
|
||||
|----------|-----|--------|
|
||||
| Daily 2:00 AM | `deleteExpiredPeers` | Removes peers past their `ExpiresAt` date. |
|
||||
| Daily 3:00 AM | `restrictOverLimitPeers` | Disables peers that exceeded `DataLimitGB`. |
|
||||
| 1st of month | `resetMonthlyUsage` | Resets `CurrentDataUsageBytes` to zero for all peers. |
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
The plugin system provides a simple notification interface. Built-in plugins include:
|
||||
|
||||
- **TelegramNotifier** -- Sends Telegram messages on events.
|
||||
- **SlackNotifier** -- Sends Slack messages on events.
|
||||
- **TrafficLogger** -- Logs traffic events for debugging.
|
||||
|
||||
Plugins are loaded at startup via `PluginManager.LoadPlugins()` and receive events through `Trigger(event, payload)`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `WG_API_KEY` | `test-api-key` | API key for header-based authentication. |
|
||||
| `JWT_SECRET` | `secret` | Secret for JWT token signing. |
|
||||
| `APP_FRONTEND_DIR` | `/var/www/frontend` | Path to built frontend assets. |
|
||||
| `WG_RPLANE_MODE` | `forward` | Default server mode (`forward` or `standalone`). |
|
||||
|
||||
### Database
|
||||
|
||||
WGRplane uses SQLite by default (`wgrplane.db`). Models auto-migrate on startup:
|
||||
|
||||
- **Server** -- WireGuard server entries with mode and endpoint.
|
||||
- **Peer** -- Peer entries with IP, access rules, data limits, expiry.
|
||||
- **Webhook** -- Webhook configurations with templates and action filters.
|
||||
- **SMTPSettings** -- SMTP server configuration for email notifications.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
03.wireguard-policy/
|
||||
├── app/ # Go backend + Vue frontend
|
||||
│ ├── main.go # Bootstrap server, routing, init DB
|
||||
│ ├── handlers.go # REST API route handlers
|
||||
│ ├── main.go # Server bootstrap, routes
|
||||
│ ├── models.go # GORM models (Server, Peer, Webhook, SMTP)
|
||||
│ ├── auth.go # JWT, TOTP, API key auth middleware
|
||||
│ ├── nftables.go # nftables rule management (forward mode)
|
||||
│ ├── nftables.go # nftables rule management (mode forward)
|
||||
│ ├── webhook.go # Webhook engine with retry/backoff
|
||||
│ ├── scheduler.go # Cron jobs (expiry, data limit, reset)
|
||||
│ ├── stats.go # WebSocket Hub for real-time stats
|
||||
│ ├── email.go # SMTP email notifications
|
||||
│ ├── plugins.go # Plugin system (Telegram, Slack, Logger)
|
||||
│ ├── validation.go # Input validators (IP, CIDR, PublicKey)
|
||||
│ ├── wg.go # WireGuard key generation, config export
|
||||
│ ├── i18n.go # Backend i18n (en/id/zh)
|
||||
│ ├── frontend/ # Vue 3 SPA (TypeScript, TailwindCSS)
|
||||
│ └── active.{en,id,zh}.json # Translation files
|
||||
├── wg-sync-policy.sh # Parses wg0.conf → policy.json
|
||||
├── wg-policy-engine.sh # Applies policy.json → iptables/ipset
|
||||
│ └── docs/ # Swagger documentation
|
||||
├── wg-sync-policy.sh # Parse wg0.conf → policy.json
|
||||
├── wg-policy-engine.sh # Apply policy.json → iptables/ipset
|
||||
├── wg-sync-watch.sh # inotifywait watcher daemon
|
||||
├── wg-policy-ctl # CLI wrapper for management
|
||||
├── wg-policy-ctl # CLI wrapper for policy management
|
||||
├── wg-policy-cleanup.sh # Cleanup script for PostDown
|
||||
├── wg-policy.service # Systemd unit for watcher daemon
|
||||
├── wgrplane.service # Systemd unit for Go backend
|
||||
├── install.sh # Automated installer (Docker + services)
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Docker Compose stack
|
||||
├── build.sh / build.bat # Installer rebuild scripts
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
This project builds upon concepts from WGDashboard (donaldzou/WGDashboard) with modifications for policy.json API integration and dynamic firewall enforcement.
|
||||
For detailed WGRplane documentation including webhooks, plugins, scheduler, and frontend details, refer to [`app/README.md`](app/README.md).
|
||||
|
||||
Submodule
+1
Submodule app added at b43866d42c
@@ -1,27 +0,0 @@
|
||||
# WireGuard Policy Firewall (`03.wireguard-policy`)
|
||||
|
||||
## Architecture & Configuration Flow
|
||||
- **Goal:** Dynamic iptables/ipset rules based on WireGuard configuration (`wg0.conf`).
|
||||
- **Data Flow:** `wg0.conf` -> `wg-sync-policy.sh` -> `policy.json` -> `wg-policy-engine.sh` -> `iptables`/`ipset`
|
||||
- **File Watcher:** `wg-sync-watch.sh` monitors `wg0.conf` via `inotifywait` and debounces changes to re-run the sync and engine.
|
||||
|
||||
## Critical Parsing Rules & Design Constraints
|
||||
- **Target IPs Parsing (`#Access`):** The firewall script uses the custom `#Access` comment in `wg0.conf` to define egress/firewall whitelists for clients.
|
||||
- **Why `#Access` is mandatory:** WireGuard's native `AllowedIPs` on a Server dictates *routing* towards the client. If we put target destinations in the Server's `AllowedIPs`, the Server would wrongly route traffic destined for those IPs *into* the client tunnel. Therefore, a custom `#Access` comment is the only correct way to define firewall whitelist destinations without breaking WireGuard's Cryptokey Routing.
|
||||
- **Do not remove `#Access`:** Future agents MUST NOT attempt to refactor the script to parse targets from `AllowedIPs`. It is architecturally incorrect for this use case.
|
||||
|
||||
## Testing & Verifying
|
||||
- `wg-policy-ctl status`: Check the overall health, including interface status, JSON validity, lock files, and iptables rules counts.
|
||||
- `wg-policy-ctl validate`: Validates `policy.json` without applying.
|
||||
- `wg-policy-ctl rules`: View the applied iptables rules in the active chain (`WG_POLICY`).
|
||||
- `wg-policy-ctl reload`: Forces a re-sync from `wg0.conf` and re-applies iptables.
|
||||
|
||||
## Script Constraints & Gotchas
|
||||
- **Atomic Operations:** Always use atomic writes (`mv -f tmp target`) for `policy.json` to prevent the policy engine from reading partial files.
|
||||
- **Locking:** `wg-sync-policy.sh` uses file-based locking (`flock`) to prevent race conditions during updates.
|
||||
- **Rollback:** `wg-policy-engine.sh` creates a backup chain (`WG_POLICY_BAK`) and uses a trap on `ERR` to rollback if applying rules fails halfway.
|
||||
- **Dependencies:** Requires `jq` and `inotify-tools`.
|
||||
|
||||
## Development Commands
|
||||
- Restart the watcher service: `systemctl restart wg-policy.service`
|
||||
- Check service logs: `journalctl -u wg-policy.service -f`
|
||||
@@ -1,15 +0,0 @@
|
||||
# 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"]
|
||||
-500
@@ -1,500 +0,0 @@
|
||||
# WGRplane - WireGuard Remote Plane Control
|
||||
|
||||
**WGRplane** adalah aplikasi WireGuard Control Plane berbasis Go-native dengan frontend Vue 3. Single binary untuk backend, SPA untuk frontend, dilengkapi dynamic policy firewall dan glassmorphism UI.
|
||||
|
||||
---
|
||||
|
||||
## Fitur Utama
|
||||
|
||||
- **Arsitektur Go-Native**: Single Go binary (`main.go`) menangani semua API, database, webhook, scheduler, dan nftables. Tidak ada Python/Flask.
|
||||
- **Peer CRUD Lengkap**: Tambah, edit, hapus peer. Generate QR code untuk import ke mobile client. Export file konfigurasi `.conf`.
|
||||
- **Hybrid Mode**: Mode `forward` (nftables lokal) atau `standalone` (webhook ke server remote).
|
||||
- **2-Column Policy UI**: Kolom "Allow Access" (firewall whitelist CIDR) dan "Allow Internet" (MASQUERADE toggle) per peer.
|
||||
- **Real-time Monitoring**: WebSocket broadcast statistik peer dan trafik setiap 5 detik.
|
||||
- **Automated Scheduling**: Cron job harian untuk hapus peer expired, restriksi peer over-limit, dan reset data usage bulanan.
|
||||
- **Security**: Autentikasi API Key (`wg-rplane-datadunia`), JWT Bearer token, dan TOTP (2FA).
|
||||
- **Webhook Engine**: Integrasi dengan server remote (Mikrotik, dll). Retry dengan exponential backoff, custom headers, Go template.
|
||||
- **Plugin System**: Notifikasi Telegram, Slack, dan Traffic Logger.
|
||||
- **i18n & Themes**: Multi-bahasa (English, Indonesian, Chinese). Dark/Light/Auto mode.
|
||||
- **Glassmorphism UI**: Desain futuristik dengan frosted glass cards, buttons, dan inputs.
|
||||
|
||||
---
|
||||
|
||||
## Arsitektur
|
||||
|
||||
```
|
||||
wg0.conf (dengan/tanpa #Access)
|
||||
↓
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ WGRplane (Go Binary :10087) │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Gorilla │ │ GORM │ │ nftables │ │
|
||||
│ │ Mux Router│ │ SQLite │ │ Engine │ │
|
||||
│ └───────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Webhook │ │ Scheduler│ │ WebSocket│ │
|
||||
│ │ Engine │ │ Cron │ │ Hub │ │
|
||||
│ └───────────┘ └──────────┘ └──────────┘ │
|
||||
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Auth │ │ SMTP │ │ Plugins │ │
|
||||
│ │ JWT/TOTP │ │ Email │ │ TG/Slack │ │
|
||||
│ └───────────┘ └──────────┘ └──────────┘ │
|
||||
└─────────────────────────────────────────────┘
|
||||
↓ HTTP/WebSocket
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ Frontend (Vue 3 + TypeScript + Tailwind) │
|
||||
│ Glassmorphism UI, i18n, Dark/Light mode │
|
||||
└─────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Data Flow Policy Firewall
|
||||
|
||||
```
|
||||
wg0.conf (#Access comments)
|
||||
↓ inotifywait detects change
|
||||
wg-sync-watch.sh (debounced trigger)
|
||||
↓
|
||||
wg-sync-policy.sh (parses wg0.conf → policy.json, atomic write)
|
||||
↓
|
||||
wg-policy-engine.sh (reads policy.json → iptables/ipset rules)
|
||||
↓
|
||||
Active firewall rules (WG_POLICY chain)
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Komponen | Teknologi |
|
||||
|-----------|------------|
|
||||
| **Backend** | Go, Gorilla Mux, GORM (SQLite via glebarez/sqlite) |
|
||||
| **Frontend** | Vue 3, TypeScript, Vite, TailwindCSS 4, vue-i18n 9 |
|
||||
| **Auth** | JWT (golang-jwt/v5), TOTP (pquerna/otp), API Key |
|
||||
| **WebSockets** | gorilla/websocket |
|
||||
| **Webhooks** | Go net/http dengan retry + exponential backoff |
|
||||
| **Scheduling** | robfig/cron v3 |
|
||||
| **QR Code** | skip2/go-qrcode |
|
||||
| **Email** | jordan-wright/email (SMTP) |
|
||||
| **Firewall** | Bash, iptables, ipset, nftables, inotify-tools, jq |
|
||||
| **Container** | Docker (multi-stage build), docker-compose |
|
||||
|
||||
---
|
||||
|
||||
## Port & Autentikasi
|
||||
|
||||
| Service | Port | Autentikasi |
|
||||
|---------|------|---------------|
|
||||
| WGRplane (Go API + Frontend) | **10087** | API Key (`wg-rplane-datadunia`) atau JWT Bearer + TOTP opsional |
|
||||
|
||||
### Metode Autentikasi
|
||||
|
||||
| Metode | Header | Catatan |
|
||||
|--------|--------|---------|
|
||||
| API Key | `wg-rplane-datadunia: <KEY>` | Diatur via env var `WG_API_KEY`. Default: `test-api-key`. |
|
||||
| JWT | `Authorization: Bearer <TOKEN>` | Expired 15 menit. Secret via env var `JWT_SECRET`. |
|
||||
| TOTP | `X-TOTP: <CODE>` | Wajib jika user mengaktifkan TOTP. |
|
||||
|
||||
---
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Semua endpoint dilayani di port `10087`.
|
||||
|
||||
### Servers
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-------------|
|
||||
| `/api/servers` | `GET` | Daftar semua server WireGuard. |
|
||||
| `/api/servers` | `POST` | Buat server baru. Body: `{name, mode, publicKey, endpoint}`. |
|
||||
| `/api/servers/{id}` | `GET` | Ambil satu server berdasarkan ID. |
|
||||
| `/api/servers/{id}` | `PUT` | Update server (name, mode, publicKey, endpoint). |
|
||||
| `/api/servers/{id}` | `DELETE` | Hapus server beserta peer dan webhook-nya. |
|
||||
|
||||
### Peers
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-------------|
|
||||
| `/api/servers/{id}/peers` | `GET` | Daftar semua peer untuk satu server. |
|
||||
| `/api/servers/{id}/peers` | `POST` | Buat peer baru. Body: `{publicKey, ip, allowAccess, allowInternet}`. Mode `forward` langsung apply nftables, mode `standalone` trigger webhook. |
|
||||
| `/api/peers/{id}` | `PUT` | Update peer. Hitung diff dan apply perubahan nftables incremental. |
|
||||
| `/api/peers/{id}` | `DELETE` | Hapus peer. Bersihkan rule nftables dan trigger webhook. |
|
||||
| `/api/peers/{id}/config` | `GET` | Download file konfigurasi WireGuard `.conf`. |
|
||||
| `/api/peers/{id}/qrcode` | `GET` | Generate QR code PNG untuk import ke mobile client. |
|
||||
|
||||
### Webhooks
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-------------|
|
||||
| `/api/servers/{id}/webhooks` | `GET` | Daftar webhook untuk satu server. |
|
||||
| `/api/servers/{id}/webhooks` | `POST` | Buat webhook. Body: `{name, url, template, customBody, customHeaders, subscribedActions, isEnabled, verifySSL}`. |
|
||||
| `/api/webhooks/{id}` | `DELETE` | Hapus webhook. |
|
||||
|
||||
### SMTP Settings
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-------------|
|
||||
| `/api/settings/smtp` | `GET` | Ambil konfigurasi SMTP saat ini. |
|
||||
| `/api/settings/smtp` | `POST` | Simpan/update pengaturan SMTP untuk notifikasi email. |
|
||||
|
||||
### Statistics
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-------------|
|
||||
| `/api/stats` | `GET` | Statistik global: total server, peer, webhook. |
|
||||
| `/api/servers/{id}/stats` | `GET` | Statistik per server: jumlah peer, webhook, detail server. |
|
||||
|
||||
### WebSocket
|
||||
|
||||
| Endpoint | Protokol | Deskripsi |
|
||||
|----------|----------|-------------|
|
||||
| `/ws/stats` | WebSocket | Broadcast statistik real-time (interval 5 detik). |
|
||||
|
||||
### TOTP Setup
|
||||
|
||||
| Endpoint | Method | Deskripsi |
|
||||
|----------|--------|-------------|
|
||||
| `/auth/setup-totp?user=<USER>` | `GET` | Generate TOTP secret dan provisioning URI untuk user. |
|
||||
|
||||
### Swagger Documentation
|
||||
|
||||
| Endpoint | Deskripsi |
|
||||
|----------|-----------|
|
||||
| `/swagger/` | Swagger UI untuk dokumentasi API interaktif. |
|
||||
|
||||
---
|
||||
|
||||
## Hybrid Mode
|
||||
|
||||
Backend Go mendukung dua mode server:
|
||||
|
||||
- **`forward`** -- Instance WGRplane langsung apply rule nftables di mesin lokal. Policy peer enforced langsung via perintah `nft`.
|
||||
- **`standalone`** -- Instance bertindak sebagai control plane yang trigger webhook ke server WireGuard remote. Policy enforcement terjadi di sisi remote.
|
||||
|
||||
### 2-Column Policy
|
||||
|
||||
Setiap peer memiliki dua kolom policy independen:
|
||||
|
||||
| Kolom | Fungsi |
|
||||
|-------|--------|
|
||||
| **AllowAccess** | Daftar CIDR yang bisa dijangkau peer (target internal). |
|
||||
| **AllowInternet** | Boolean flag. Jika `true`, peer mendapat akses internet tanpa batas (MASQUERADE). |
|
||||
|
||||
Peer yang tidak punya rule apapun terisolasi dari peer lain dan dari internet secara default.
|
||||
|
||||
---
|
||||
|
||||
## Instalasi
|
||||
|
||||
### Opsi 1: Docker Compose (Direkomendasikan)
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://git.datadunia.com/hainzero/WGRplane.git
|
||||
cd 03.wireguard-policy
|
||||
|
||||
# Start WGRplane dan WireGuard
|
||||
docker compose up -d
|
||||
|
||||
# Akses dashboard
|
||||
# http://localhost:10087
|
||||
```
|
||||
|
||||
Stack compose menjalankan:
|
||||
- **WGRplane** di port `10087` (Go API + Vue frontend)
|
||||
- **WireGuard** container dengan host networking untuk akses kernel module
|
||||
|
||||
### Opsi 2: Install Script
|
||||
|
||||
```bash
|
||||
# Jalankan installer otomatis (Ubuntu/Debian/CentOS)
|
||||
sudo ./install.sh install
|
||||
|
||||
# Uninstall
|
||||
sudo ./install.sh uninstall
|
||||
```
|
||||
|
||||
Install script menangani instalasi Docker, cloning repository, pembuatan `.env`, dan startup service.
|
||||
|
||||
### Opsi 3: Manual Build
|
||||
|
||||
```bash
|
||||
# Build binary Go
|
||||
cd app
|
||||
go build -o ../wgrplane .
|
||||
cd ..
|
||||
|
||||
# Build frontend
|
||||
cd app/frontend
|
||||
npm install && npm run build
|
||||
cd ../..
|
||||
|
||||
# Jalankan
|
||||
./wgrplane
|
||||
# Server start di :10087
|
||||
```
|
||||
|
||||
### Opsi 4: Systemd Service
|
||||
|
||||
```bash
|
||||
# Install service file
|
||||
sudo cp wgrplane.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable wgrplane.service
|
||||
sudo systemctl start wgrplane.service
|
||||
|
||||
# Lihat log
|
||||
journalctl -u wgrplane.service -f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Konfigurasi
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Default | Deskripsi |
|
||||
|----------|---------|-----------|
|
||||
| `WG_API_KEY` | `test-api-key` | API key untuk autentikasi header-based. |
|
||||
| `JWT_SECRET` | `secret` | Secret untuk signing JWT token. |
|
||||
| `APP_FRONTEND_DIR` | `/var/www/frontend` | Path ke asset frontend yang sudah di-build. |
|
||||
| `WG_RPLANE_MODE` | `forward` | Mode server default (`forward` atau `standalone`). |
|
||||
|
||||
### Database
|
||||
|
||||
WGRplane menggunakan SQLite secara default (`wgrplane.db`). Model auto-migrate saat startup:
|
||||
|
||||
- **Server** -- Entry server WireGuard dengan mode dan endpoint.
|
||||
- **Peer** -- Entry peer dengan IP, rule akses, limit data, expiry.
|
||||
- **Webhook** -- Konfigurasi webhook dengan template dan filter aksi.
|
||||
- **SMTPSettings** -- Konfigurasi server SMTP untuk notifikasi email.
|
||||
|
||||
---
|
||||
|
||||
## Scheduler
|
||||
|
||||
Backend Go menjalankan tiga cron job:
|
||||
|
||||
| Jadwal | Job | Aksi |
|
||||
|--------|-----|------|
|
||||
| Setiap hari 02:00 | `deleteExpiredPeers` | Hapus peer yang sudah melewati `ExpiresAt`. |
|
||||
| Setiap hari 03:00 | `restrictOverLimitPeers` | Disable peer yang melebihi `DataLimitGB`. |
|
||||
| Tanggal 1 setiap bulan | `resetMonthlyUsage` | Reset `CurrentDataUsageBytes` ke nol untuk semua peer. |
|
||||
|
||||
---
|
||||
|
||||
## Webhook Payload
|
||||
|
||||
Webhook trigger pada event lifecycle peer (`peer_created`, `peer_updated`, `peer_deleted`, `policy_changed`). Engine mendukung tiga mode template: `default` (JSON mentah), `mikrotik` (format RouterOS), dan `custom` (Go template).
|
||||
|
||||
### Fitur Webhook
|
||||
|
||||
- **Retry dengan backoff**: Delivery gagal retry hingga 3 kali dengan exponential backoff (2s, 4s, 8s).
|
||||
- **Verifikasi SSL**: Bisa di-toggle per-webhook via `verifySSL`.
|
||||
- **Custom headers**: Inject header per-webhook via `customHeaders` JSON.
|
||||
- **Global webhooks**: Set `isGlobal: true` untuk trigger di semua server.
|
||||
- **Filter aksi**: Subscribe ke event spesifik via array `subscribedActions`.
|
||||
|
||||
---
|
||||
|
||||
## UI Frontend
|
||||
|
||||
Frontend adalah Vue 3 + TypeScript SPA dengan desain glassmorphism.
|
||||
|
||||
### Design System
|
||||
|
||||
- **Glassmorphism**: Frosted glass cards, buttons, dan inputs dengan efek backdrop blur.
|
||||
- **TailwindCSS 4**: Utility-first styling dengan dukungan dark mode penuh via `dark:` variants.
|
||||
- **Responsive**: Layout mobile-first yang adaptif di semua ukuran layar.
|
||||
|
||||
### Fitur UI
|
||||
|
||||
| Fitur | Deskripsi |
|
||||
|-------|-----------|
|
||||
| **Multi-bahasa (i18n)** | English, Indonesian, dan Chinese via vue-i18n 9. Locale auto-detect dari browser. |
|
||||
| **Theme Switching** | Dark / Light / Auto (ikuti preferensi sistem) via `@vueuse/core`. |
|
||||
| **Real-time Stats** | Koneksi WebSocket broadcast jumlah peer dan data trafik setiap 5 detik. |
|
||||
| **Charts** | Visualisasi trafik via Chart.js + vue-chartjs. |
|
||||
| **Toast Notifications** | Alert non-intrusif via vue-sonner. |
|
||||
| **QR Code Import** | Generate QR code yang bisa di-scan untuk setup mobile WireGuard client. |
|
||||
|
||||
### Struktur Frontend
|
||||
|
||||
```
|
||||
app/frontend/src/
|
||||
├── App.vue # Root component dengan provider theme/i18n
|
||||
├── main.ts # Bootstrap app (Vue, Router, i18n)
|
||||
├── router/ # Definisi Vue Router
|
||||
├── i18n/ # File locale (en.json, id.json, zh.json)
|
||||
├── components/ # Komponen Glass UI (Card, Button, Input, Toggle)
|
||||
├── composables/ # Vue composables (useTheme, useWebSocket)
|
||||
├── views/ # Page components (Dashboard, Servers, Peers, Settings, Webhooks)
|
||||
└── types/ # Definisi tipe TypeScript
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Policy Firewall (`#Access`)
|
||||
|
||||
Engine policy berbasis shell enforcing rule firewall per-peer langsung dari `wg0.conf`.
|
||||
|
||||
### Cara Kerja
|
||||
|
||||
1. Tambahkan komentar `#Access` di bawah setiap blok `[Peer]` di `wg0.conf`.
|
||||
2. Watcher daemon (`wg-sync-watch.sh`) mendeteksi perubahan file via `inotifywait`.
|
||||
3. `wg-sync-policy.sh` parse config dan tulis `policy.json` secara atomik.
|
||||
4. `wg-policy-engine.sh` baca JSON dan apply rule iptables/ipset.
|
||||
|
||||
### Contoh `wg0.conf`
|
||||
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 10.0.0.1/24
|
||||
ListenPort = 51820
|
||||
PrivateKey = <SERVER_PRIVATE_KEY>
|
||||
|
||||
PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh
|
||||
PostDown = /usr/local/bin/wg-policy-cleanup.sh
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_1_PUBKEY>
|
||||
AllowedIPs = 10.0.0.2/32
|
||||
#Access 192.168.1.10/32, 192.168.12.0/24
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_2_PUBKEY>
|
||||
AllowedIPs = 10.0.0.3/32
|
||||
#Access 10.0.0.1/32
|
||||
|
||||
[Peer]
|
||||
PublicKey = <CLIENT_3_PUBKEY>
|
||||
AllowedIPs = 10.0.0.4/32
|
||||
#Access
|
||||
# Access kosong = internet-only, isolasi client berlaku
|
||||
```
|
||||
|
||||
### Mengapa `#Access` Bukan `AllowedIPs`?
|
||||
|
||||
WireGuard menggunakan `AllowedIPs` untuk Cryptokey Routing. Memasukkan IP destinasi di `AllowedIPs` server akan menyebabkan WireGuard meroute traffic untuk IP tersebut ke dalam tunnel client. Komentar `#Access` memisahkan konfigurasi firewall dari routing secara bersih.
|
||||
|
||||
### CLI: `wg-policy-ctl`
|
||||
|
||||
```bash
|
||||
wg-policy-ctl status # Health check, status lock, jumlah rule
|
||||
wg-policy-ctl policy # Lihat raw policy.json
|
||||
wg-policy-ctl rules # Inspect rule iptables aktif
|
||||
wg-policy-ctl ipset # Lihat mapping ipset
|
||||
wg-policy-ctl reload # Force re-sync dan re-apply
|
||||
wg-policy-ctl log # Lihat log packet dropped
|
||||
wg-policy-ctl stats # Statistik koneksi
|
||||
wg-policy-ctl validate # Validasi schema policy.json
|
||||
```
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Package | Diperlukan | Install |
|
||||
|---------|------------|---------|
|
||||
| `jq` | Ya | `apt install jq` |
|
||||
| `inotify-tools` | Ya (watcher daemon) | `apt install inotify-tools` |
|
||||
| `ipset` | Opsional | `apt install ipset` |
|
||||
|
||||
Tanpa `ipset`, engine fallback ke entry iptables per-rule. Ini bekerja untuk deployment kecil. Untuk jumlah peer besar, `ipset` memberikan performa lookup O(1).
|
||||
|
||||
---
|
||||
|
||||
## Plugins
|
||||
|
||||
Sistem plugin menyediakan interface notifikasi sederhana. Plugin bawaan:
|
||||
|
||||
- **TelegramNotifier** -- Kirim pesan Telegram saat event.
|
||||
- **SlackNotifier** -- Kirim pesan Slack saat event.
|
||||
- **TrafficLogger** -- Log event traffic untuk debugging.
|
||||
|
||||
Plugin dimuat saat startup via `PluginManager.LoadPlugins()` dan menerima event melalui `Trigger(event, payload)`.
|
||||
|
||||
---
|
||||
|
||||
## Struktur Proyek
|
||||
|
||||
```
|
||||
03.wireguard-policy/
|
||||
├── app/ # Go backend + Vue frontend
|
||||
│ ├── main.go # Bootstrap server, routing, init DB
|
||||
│ ├── handlers.go # REST API route handlers
|
||||
│ ├── models.go # GORM models (Server, Peer, Webhook, SMTP)
|
||||
│ ├── auth.go # JWT, TOTP, API key auth middleware
|
||||
│ ├── nftables.go # nftables rule management (mode forward)
|
||||
│ ├── webhook.go # Webhook engine dengan retry/backoff
|
||||
│ ├── scheduler.go # Cron jobs (expiry, data limit, reset)
|
||||
│ ├── stats.go # WebSocket Hub untuk real-time stats
|
||||
│ ├── email.go # Notifikasi email SMTP
|
||||
│ ├── plugins.go # Plugin system (Telegram, Slack, Logger)
|
||||
│ ├── validation.go # Input validators (IP, CIDR, PublicKey)
|
||||
│ ├── wg.go # WireGuard key generation, config export
|
||||
│ ├── i18n.go # Backend i18n (en/id/zh)
|
||||
│ ├── docs/ # Swagger documentation
|
||||
│ └── frontend/ # Vue 3 SPA (TypeScript, TailwindCSS)
|
||||
├── wg-sync-policy.sh # Parse wg0.conf → policy.json
|
||||
├── wg-policy-engine.sh # Apply policy.json → iptables/ipset
|
||||
├── wg-sync-watch.sh # inotifywait watcher daemon
|
||||
├── wg-policy-ctl # CLI wrapper untuk manajemen
|
||||
├── wg-policy-cleanup.sh # Cleanup script untuk PostDown
|
||||
├── wg-policy.service # Systemd unit untuk watcher daemon
|
||||
├── wgrplane.service # Systemd unit untuk Go backend
|
||||
├── install.sh # Automated installer (Docker + services)
|
||||
├── Dockerfile # Multi-stage Docker build
|
||||
├── docker-compose.yml # Docker Compose stack
|
||||
├── build.sh / build.bat # Script rebuild installer
|
||||
└── README.md # Dokumentasi ini
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual QA
|
||||
|
||||
```bash
|
||||
# Test auth (tanpa header)
|
||||
curl http://localhost:10087/api/servers
|
||||
# Expected: 401 Unauthorized
|
||||
|
||||
# Test auth (header salah)
|
||||
curl -H "wg-rplane-datadunia: wrong" http://localhost:10087/api/servers
|
||||
# Expected: 401 Unauthorized
|
||||
|
||||
# Test auth (header benar)
|
||||
curl -H "wg-rplane-datadunia: test-api-key" http://localhost:10087/api/servers
|
||||
# Expected: 200 OK, daftar server
|
||||
|
||||
# Test buat server
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "wg-rplane-datadunia: test-api-key" \
|
||||
-d '{"name":"wg-01","mode":"forward","publicKey":"<PUBKEY>","endpoint":"vpn.example.com:51820"}' \
|
||||
http://localhost:10087/api/servers
|
||||
|
||||
# Test buat peer
|
||||
curl -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "wg-rplane-datadunia: test-api-key" \
|
||||
-d '{"publicKey":"<PEER_PUBKEY>","ip":"10.0.0.2","allowAccess":["192.168.1.0/24"],"allowInternet":true}' \
|
||||
http://localhost:10087/api/servers/1/peers
|
||||
|
||||
# Test QR code
|
||||
curl -H "wg-rplane-datadunia: test-api-key" http://localhost:10087/api/peers/1/qrcode --output peer-qr.png
|
||||
|
||||
# Verifikasi rule iptables
|
||||
wg-policy-ctl rules
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dokumentasi Tambahan
|
||||
|
||||
- **Plan File**: `plan.md` (detail rencana implementasi)
|
||||
- **AGENTS.md**: Panduan untuk AI agent dalam mengembangkan proyek ini
|
||||
- **Parent Repo**: `https://git.datadunia.com/hainzero/WGRplane.git` (submodule di `/app`)
|
||||
|
||||
---
|
||||
|
||||
## Lisensi
|
||||
|
||||
Proyek ini membangun konsep dari WGDashboard (donaldzou/WGDashboard) dengan modifikasi untuk integrasi policy.json API dan dynamic firewall enforcement.
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"greeting": "Hello",
|
||||
"farewell": "Goodbye"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"greeting": "Halo",
|
||||
"farewell": "Selamat tinggal"
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
{
|
||||
"greeting": "你好",
|
||||
"farewell": "再见"
|
||||
}
|
||||
-146
@@ -1,146 +0,0 @@
|
||||
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,777 +0,0 @@
|
||||
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"
|
||||
-134
@@ -1,134 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/smtp"
|
||||
"text/template"
|
||||
|
||||
"github.com/jordan-wright/email"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EmailTemplate struct {
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
var emailTemplates = map[string]EmailTemplate{
|
||||
"peer_created": {
|
||||
Subject: "New Peer Created: {{.Peer.IP}}",
|
||||
Body: "Peer {{.Peer.IP}} was created on server {{.Server.Name}}.\nPublic Key: {{.Peer.PublicKey}}",
|
||||
},
|
||||
"peer_deleted": {
|
||||
Subject: "Peer Deleted: {{.Peer.IP}}",
|
||||
Body: "Peer {{.Peer.IP}} was deleted from server {{.Server.Name}}.",
|
||||
},
|
||||
"policy_changed": {
|
||||
Subject: "Policy Updated for {{.Peer.IP}}",
|
||||
Body: "Policy updated for peer {{.Peer.IP}} on server {{.Server.Name}}.\nChanges: {{range .Policy.Changes}}{{.}} {{end}}",
|
||||
},
|
||||
}
|
||||
|
||||
func GetSMTPSettings() (settings SMTPSettings, err error) {
|
||||
err = db.First(&settings).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return SMTPSettings{}, nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func SaveSMTPSettings(settings SMTPSettings) error {
|
||||
var existing SMTPSettings
|
||||
if err := db.First(&existing).Error; err == gorm.ErrRecordNotFound {
|
||||
return db.Create(&settings).Error
|
||||
}
|
||||
return db.Model(&existing).Updates(settings).Error
|
||||
}
|
||||
|
||||
func SendEmail(to, templateName string, data map[string]interface{}) error {
|
||||
settings, err := GetSMTPSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !settings.Enabled {
|
||||
return fmt.Errorf("SMTP is disabled")
|
||||
}
|
||||
|
||||
template, ok := emailTemplates[templateName]
|
||||
if !ok {
|
||||
return fmt.Errorf("template %s not found", templateName)
|
||||
}
|
||||
|
||||
subject, err := renderTemplate(template.Subject, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := renderTemplate(template.Body, data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
e := email.NewEmail()
|
||||
e.From = fmt.Sprintf("%s <%s>", settings.FromName, settings.FromEmail)
|
||||
e.To = []string{to}
|
||||
e.Subject = subject
|
||||
e.Text = []byte(body)
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", settings.Server, settings.Port)
|
||||
if settings.UseAuth {
|
||||
return e.Send(addr, smtp.PlainAuth("", settings.Username, settings.Password, settings.Server))
|
||||
}
|
||||
return e.Send(addr, nil)
|
||||
}
|
||||
|
||||
func TestSMTP(settings SMTPSettings) error {
|
||||
e := email.NewEmail()
|
||||
e.From = fmt.Sprintf("%s <%s>", settings.FromName, settings.FromEmail)
|
||||
e.To = []string{settings.FromEmail}
|
||||
e.Subject = "WGRplane SMTP Test"
|
||||
e.Text = []byte("This is a test email from WGRplane.")
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", settings.Server, settings.Port)
|
||||
if settings.UseAuth {
|
||||
return e.Send(addr, smtp.PlainAuth("", settings.Username, settings.Password, settings.Server))
|
||||
}
|
||||
return e.Send(addr, nil)
|
||||
}
|
||||
|
||||
func renderTemplate(tmpl string, data map[string]interface{}) (string, error) {
|
||||
t, err := template.New("email").Parse(tmpl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := t.Execute(&buf, data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func NotifyPeerCreated(server *Server, peer *Peer) error {
|
||||
data := map[string]interface{}{
|
||||
"Server": server,
|
||||
"Peer": peer,
|
||||
}
|
||||
return SendEmail(server.Endpoint, "peer_created", data)
|
||||
}
|
||||
|
||||
func NotifyPeerDeleted(server *Server, peer *Peer) error {
|
||||
data := map[string]interface{}{
|
||||
"Server": server,
|
||||
"Peer": peer,
|
||||
}
|
||||
return SendEmail(server.Endpoint, "peer_deleted", data)
|
||||
}
|
||||
|
||||
func NotifyPolicyChanged(server *Server, peer *Peer, changes []string) error {
|
||||
data := map[string]interface{}{
|
||||
"Server": server,
|
||||
"Peer": peer,
|
||||
"Policy": Policy{Action: "updated", Changes: changes},
|
||||
}
|
||||
return SendEmail(server.Endpoint, "policy_changed", data)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -1,13 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WGRplane - WireGuard Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
-1885
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.4",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss": "^8.5.13",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.10"
|
||||
},
|
||||
"dependencies": {
|
||||
"@headlessui/vue": "^1.7.23",
|
||||
"@vueuse/core": "^14.3.0",
|
||||
"chart.js": "^4.5.1",
|
||||
"vue": "^3.5.33",
|
||||
"vue-chartjs": "^5.3.3",
|
||||
"vue-i18n": "^9.14.5",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue-sonner": "^2.0.9"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
@@ -1,24 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
@@ -1,161 +0,0 @@
|
||||
<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>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 13 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#007ACC" d="M0 128v128h256V0H0z"/><path fill="#FFF" d="m56.612 128.85l-.081 10.483h33.32v94.68h23.568v-94.68h33.321v-10.28c0-5.69-.122-10.444-.284-10.566c-.122-.162-20.4-.244-44.983-.203l-44.74.122l-.121 10.443Zm149.955-10.742c6.501 1.625 11.459 4.51 16.01 9.224c2.357 2.52 5.851 7.111 6.136 8.208c.08.325-11.053 7.802-17.798 11.988c-.244.162-1.22-.894-2.317-2.52c-3.291-4.795-6.745-6.867-12.028-7.233c-7.76-.528-12.759 3.535-12.718 10.321c0 1.992.284 3.17 1.097 4.795c1.707 3.536 4.876 5.649 14.832 9.956c18.326 7.883 26.168 13.084 31.045 20.48c5.445 8.249 6.664 21.415 2.966 31.208c-4.063 10.646-14.14 17.879-28.323 20.276c-4.388.772-14.79.65-19.504-.203c-10.28-1.828-20.033-6.908-26.047-13.572c-2.357-2.6-6.949-9.387-6.664-9.874c.122-.163 1.178-.813 2.356-1.504c1.138-.65 5.446-3.129 9.509-5.485l7.355-4.267l1.544 2.276c2.154 3.29 6.867 7.801 9.712 9.305c8.167 4.307 19.383 3.698 24.909-1.26c2.357-2.153 3.332-4.388 3.332-7.68c0-2.966-.366-4.266-1.91-6.501c-1.99-2.845-6.054-5.242-17.595-10.24c-13.206-5.69-18.895-9.224-24.096-14.832c-3.007-3.25-5.852-8.452-7.03-12.8c-.975-3.617-1.22-12.678-.447-16.335c2.723-12.76 12.353-21.659 26.25-24.3c4.51-.853 14.994-.528 19.424.569Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,61 +0,0 @@
|
||||
<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>
|
||||
@@ -1,93 +0,0 @@
|
||||
<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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
@@ -1,43 +0,0 @@
|
||||
<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>
|
||||
@@ -1,76 +0,0 @@
|
||||
<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>
|
||||
@@ -1,29 +0,0 @@
|
||||
<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>
|
||||
@@ -1,402 +0,0 @@
|
||||
<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>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8 text-white/50">Loading peers...</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<table v-else 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, onMounted } 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[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fetchPeers = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await fetch(`/api/servers/${props.serverId}/peers`)
|
||||
if (!response.ok) throw new Error('Failed to fetch peers')
|
||||
peers.value = await response.json()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'An error occurred'
|
||||
console.error('Fetch peers error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchPeers()
|
||||
})
|
||||
|
||||
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')
|
||||
isEditPeerModalOpen.value = false
|
||||
selectedPeerForEdit.value = null
|
||||
await fetchPeers()
|
||||
} 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')
|
||||
isAddPeerModalOpen.value = false
|
||||
newPeer.value = { publicKey: '', ip: '', allowedAccess: [], allowInternet: false, expiresAt: null, dataLimitGB: null }
|
||||
await fetchPeers()
|
||||
} 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')
|
||||
await fetchPeers()
|
||||
} 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>
|
||||
@@ -1,26 +0,0 @@
|
||||
<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>
|
||||
@@ -1,127 +0,0 @@
|
||||
<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>
|
||||
@@ -1,92 +0,0 @@
|
||||
<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,8 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,5 +0,0 @@
|
||||
<template>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +0,0 @@
|
||||
<template>
|
||||
<input
|
||||
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"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
class="relative w-12 h-6 rounded-full transition-colors"
|
||||
:class="modelValue ? 'bg-blue-500' : 'dark:bg-gray-600 bg-gray-300'"
|
||||
@click="$emit('update:modelValue', !modelValue)"
|
||||
>
|
||||
<span
|
||||
class="absolute top-1 left-1 w-4 h-4 bg-white rounded-full transition-transform"
|
||||
:class="modelValue ? 'translate-x-6' : ''"
|
||||
></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,36 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export function setupCounter(element: HTMLButtonElement) {
|
||||
let counter = 0
|
||||
const setCounter = (count: number) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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
|
||||
}
|
||||
})
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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": "保存设置"
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { i18n } from './i18n'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
app.mount('#app')
|
||||
@@ -1,45 +0,0 @@
|
||||
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
|
||||
Vendored
-5
@@ -1,5 +0,0 @@
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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'
|
||||
}
|
||||
]
|
||||
@@ -1,188 +0,0 @@
|
||||
<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:10087/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>
|
||||
@@ -1,20 +0,0 @@
|
||||
<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>
|
||||
@@ -1,84 +0,0 @@
|
||||
<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>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8 text-white/50">Loading server information...</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-if="error" class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Server Information -->
|
||||
<GlassCard v-if="server" class="p-6">
|
||||
<h2 class="text-xl font-semibold text-cyan-400 mb-4">Server Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-white/50 text-sm">Server Name</p>
|
||||
<p class="text-white text-lg">{{ server.name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white/50 text-sm">Mode</p>
|
||||
<span
|
||||
class="inline-block px-3 py-1 rounded-full text-sm"
|
||||
:class="server.mode === 'forward' ? 'bg-cyan-500/20 text-cyan-400' : 'bg-purple-500/20 text-purple-400'"
|
||||
>
|
||||
{{ server.mode }}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white/50 text-sm">Public Key</p>
|
||||
<p class="text-white font-mono text-sm break-all">{{ server.publicKey }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-white/50 text-sm">Endpoint</p>
|
||||
<p class="text-white font-mono text-sm">{{ server.endpoint || 'Not set' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<!-- Peer Table -->
|
||||
<PeerTable v-if="server" :server-id="$route.params.id as string" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import GlassCard from '../components/glass/GlassCard.vue'
|
||||
import GlassButton from '../components/glass/GlassButton.vue'
|
||||
import PeerTable from '../components/PeerTable.vue'
|
||||
|
||||
interface Server {
|
||||
id: string
|
||||
name: string
|
||||
mode: 'forward' | 'standalone'
|
||||
publicKey: string
|
||||
endpoint?: string
|
||||
}
|
||||
|
||||
const server = ref<Server | null>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const serverId = (window as any).$route?.params?.id || '1'
|
||||
const response = await fetch(`/api/servers/${serverId}`)
|
||||
if (!response.ok) throw new Error('Failed to fetch server details')
|
||||
server.value = await response.json()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'An error occurred'
|
||||
console.error('Failed to load server:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,222 +0,0 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-3xl font-bold text-white">{{ t('settings') }}</h1>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8 text-white/50">Loading settings...</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Success State -->
|
||||
<div v-if="success" class="p-4 bg-green-500/20 border border-green-500/50 rounded-lg text-green-400">
|
||||
{{ success }}
|
||||
</div>
|
||||
|
||||
<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" :disabled="loading">{{ t('testEmail') }}</GlassButton>
|
||||
<GlassButton @click="saveSettings" class="from-green-500 to-teal-500" :disabled="loading">{{ t('saveSettings') }}</GlassButton>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, onMounted } 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 loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const success = ref<string | null>(null)
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/settings/smtp')
|
||||
if (!response.ok) throw new Error('Failed to fetch SMTP settings')
|
||||
const data = await response.json()
|
||||
Object.assign(smtpSettings, data)
|
||||
} catch (err) {
|
||||
console.error('Fetch SMTP settings error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSettings()
|
||||
})
|
||||
|
||||
const switchLanguage = (lang: string) => {
|
||||
locale.value = lang
|
||||
}
|
||||
|
||||
const testEmail = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
success.value = null
|
||||
try {
|
||||
const response = await fetch('/api/settings/smtp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...smtpSettings, test: true })
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to test email')
|
||||
success.value = 'Test email sent successfully!'
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to test email'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
success.value = null
|
||||
try {
|
||||
const response = await fetch('/api/settings/smtp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(smtpSettings)
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to save settings')
|
||||
success.value = 'Settings saved successfully!'
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Failed to save settings'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,165 +0,0 @@
|
||||
<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>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="text-center py-8 text-white/50">Loading webhooks...</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="error" class="p-4 bg-red-500/20 border border-red-500/50 rounded-lg text-red-400">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<GlassCard v-else 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, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
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 type { Webhook } from '../types/webhook'
|
||||
|
||||
const route = useRoute()
|
||||
const webhooks = ref<Webhook[]>([])
|
||||
const showForm = ref(false)
|
||||
const editingWebhook = ref<Webhook | undefined>(undefined)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const fetchWebhooks = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const serverId = route.params.id as string
|
||||
const response = await fetch(`/api/servers/${serverId}/webhooks`)
|
||||
if (!response.ok) throw new Error('Failed to fetch webhooks')
|
||||
webhooks.value = await response.json()
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'An error occurred'
|
||||
console.error('Fetch webhooks error:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchWebhooks()
|
||||
})
|
||||
|
||||
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 = async (webhookData: Omit<Webhook, 'id' | 'createdAt' | 'updatedAt'>) => {
|
||||
try {
|
||||
const serverId = route.params.id as string
|
||||
if (editingWebhook.value) {
|
||||
const response = await fetch(`/api/webhooks/${editingWebhook.value.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(webhookData)
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to update webhook')
|
||||
} else {
|
||||
const response = await fetch(`/api/servers/${serverId}/webhooks`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(webhookData)
|
||||
})
|
||||
if (!response.ok) throw new Error('Failed to create webhook')
|
||||
}
|
||||
closeForm()
|
||||
await fetchWebhooks()
|
||||
} catch (err) {
|
||||
console.error('Save webhook error:', err)
|
||||
alert('Failed to save webhook. Check console for details.')
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWebhook = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this webhook?')) return
|
||||
try {
|
||||
const response = await fetch(`/api/webhooks/${id}`, { method: 'DELETE' })
|
||||
if (!response.ok) throw new Error('Failed to delete webhook')
|
||||
await fetchWebhooks()
|
||||
} catch (err) {
|
||||
console.error('Delete webhook error:', err)
|
||||
alert('Failed to delete webhook. Check console for details.')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,20 +0,0 @@
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
backdropBlur: {
|
||||
xs: '2px',
|
||||
},
|
||||
boxShadow: {
|
||||
glass: '0 8px 32px 0 rgba(31, 38, 135, 0.37)',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
],
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2023",
|
||||
"module": "esnext",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
||||
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
|
||||
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
|
||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
-785
@@ -1,785 +0,0 @@
|
||||
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
@@ -1,48 +0,0 @@
|
||||
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
|
||||
}
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
httpSwagger "github.com/swaggo/http-swagger"
|
||||
_ "github.com/your-org/03.wireguard-policy/app/docs"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// @title WGRplane API
|
||||
// @version 1.0
|
||||
// @description WireGuard Control Plane with Dynamic Policy Firewall. REST API for managing WireGuard servers, peers, webhooks, SMTP settings, and real-time stats.
|
||||
// @contact.name API Support
|
||||
// @host localhost:10087
|
||||
// @BasePath /
|
||||
// @securityDefinitions.apikey ApiKeyAuth
|
||||
// @in header
|
||||
// @name wg-rplane-datadunia
|
||||
// @securityDefinitions.apikey BearerAuth
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// Global plugin manager (optional for runtime notifications)
|
||||
var pluginManager *PluginManager
|
||||
|
||||
func initApp() *gorm.DB {
|
||||
// Initialize the database. We use a local SQLite database in the app folder.
|
||||
dbConn, err := gorm.Open(sqlite.Open("wgrplane.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
// Auto-migrate all relevant models
|
||||
if err := dbConn.AutoMigrate(&Server{}, &Peer{}, &Webhook{}, &SMTPSettings{}); err != nil {
|
||||
log.Fatalf("failed to migrate database: %v", err)
|
||||
}
|
||||
// Expose the global db for handlers.go
|
||||
db = dbConn
|
||||
return dbConn
|
||||
}
|
||||
|
||||
// Entry point to bootstrapped server
|
||||
func main() {
|
||||
// Initialize DB and migrations
|
||||
db := initApp()
|
||||
_ = db // keep reference for linter
|
||||
|
||||
// Initialize plugin manager and load sample plugins
|
||||
pluginManager = NewPluginManager()
|
||||
pluginManager.LoadPlugins()
|
||||
|
||||
// Initialize background components
|
||||
initWebhookEngine()
|
||||
initScheduler()
|
||||
|
||||
// Initialize HTTP routes
|
||||
r := mux.NewRouter()
|
||||
// Servers
|
||||
r.HandleFunc("/api/servers", getServers).Methods("GET")
|
||||
r.HandleFunc("/api/servers", createServer).Methods("POST")
|
||||
r.HandleFunc("/api/servers/{id}", getServer).Methods("GET")
|
||||
r.HandleFunc("/api/servers/{id}", updateServer).Methods("PUT")
|
||||
r.HandleFunc("/api/servers/{id}", deleteServer).Methods("DELETE")
|
||||
// Peers
|
||||
r.HandleFunc("/api/servers/{id}/peers", getServerPeers).Methods("GET")
|
||||
r.HandleFunc("/api/servers/{id}/peers", createServerPeer).Methods("POST")
|
||||
r.HandleFunc("/api/peers/{id}", updatePeer).Methods("PUT")
|
||||
r.HandleFunc("/api/peers/{id}", deletePeer).Methods("DELETE")
|
||||
// SMTP settings
|
||||
r.HandleFunc("/api/settings/smtp", getSMTPSettings).Methods("GET")
|
||||
r.HandleFunc("/api/settings/smtp", setSMTPSettings).Methods("POST")
|
||||
// Webhooks
|
||||
r.HandleFunc("/api/servers/{id}/webhooks", getServerWebhooks).Methods("GET")
|
||||
r.HandleFunc("/api/servers/{id}/webhooks", createServerWebhook).Methods("POST")
|
||||
r.HandleFunc("/api/webhooks/{id}", deleteWebhook).Methods("DELETE")
|
||||
// Peer config / QR
|
||||
r.HandleFunc("/api/peers/{id}/config", getPeerConfig).Methods("GET")
|
||||
r.HandleFunc("/api/peers/{id}/qrcode", getPeerQRCode).Methods("GET")
|
||||
// Stats
|
||||
r.HandleFunc("/api/stats", getStats).Methods("GET")
|
||||
r.HandleFunc("/api/servers/{id}/stats", getServerStats).Methods("GET")
|
||||
|
||||
// Swagger documentation
|
||||
r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler)
|
||||
|
||||
// Start HTTP server
|
||||
srv := &http.Server{Addr: ":10087", Handler: r, ReadTimeout: 5 * time.Second, WriteTimeout: 10 * time.Second}
|
||||
log.Println("server: listening on :10087")
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatalf("server: failed to start: %v", err)
|
||||
}
|
||||
}
|
||||
-106
@@ -1,106 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"time"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type JSON []byte
|
||||
|
||||
// Global database handle shared across the application
|
||||
var db *gorm.DB
|
||||
|
||||
func (j JSON) Value() (driver.Value, error) {
|
||||
if j == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return string(j), nil
|
||||
}
|
||||
|
||||
func (j *JSON) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*j = nil
|
||||
return nil
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
*j = JSON(v)
|
||||
case string:
|
||||
*j = JSON(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (j JSON) MarshalJSON() ([]byte, error) {
|
||||
if j == nil {
|
||||
return []byte("null"), nil
|
||||
}
|
||||
return j, nil
|
||||
}
|
||||
|
||||
func (j *JSON) UnmarshalJSON(data []byte) error {
|
||||
*j = JSON(data)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Name string `gorm:"uniqueIndex;not null"`
|
||||
Mode string `gorm:"not null;default:forward"`
|
||||
PublicKey string `gorm:"not null"`
|
||||
Endpoint string
|
||||
Webhooks []Webhook `gorm:"foreignKey:ServerID"`
|
||||
Peers []Peer `gorm:"foreignKey:ServerID"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Peer struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ServerID uint `gorm:"not null;index"`
|
||||
PublicKey string `gorm:"uniqueIndex;not null"`
|
||||
IP string `gorm:"not null"`
|
||||
AllowAccess JSON `gorm:"type:text"`
|
||||
AllowInternet bool `gorm:"default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
// ExpiresAt defines when this peer should be considered expired and eligible for auto-deletion
|
||||
ExpiresAt time.Time
|
||||
// DataLimitGB defines the monthly data limit per peer (in GB). 0 means unlimited.
|
||||
DataLimitGB int64
|
||||
// CurrentDataUsageBytes tracks the amount of data used by this peer (in bytes)
|
||||
CurrentDataUsageBytes int64
|
||||
// Enabled indicates whether the peer is active. Auto-restrict disables the peer if over the limit.
|
||||
Enabled bool `gorm:"default:true"`
|
||||
}
|
||||
|
||||
type Webhook struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
ServerID uint `gorm:"index"`
|
||||
Name string `gorm:"not null"`
|
||||
URL string `gorm:"not null"`
|
||||
Template string `gorm:"default:default"`
|
||||
CustomBody string `gorm:"type:text"`
|
||||
DefaultPayload string `gorm:"type:text"`
|
||||
VerifySSL bool `gorm:"default:true"`
|
||||
CustomHeaders JSON `gorm:"type:text"`
|
||||
SubscribedActions JSON `gorm:"type:text"`
|
||||
IsEnabled bool `gorm:"default:true"`
|
||||
IsGlobal bool `gorm:"default:false"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type SMTPSettings struct {
|
||||
ID uint `gorm:"primaryKey"`
|
||||
Enabled bool `gorm:"default:false"`
|
||||
Server string `gorm:"default:smtp.gmail.com"`
|
||||
Port int `gorm:"default:587"`
|
||||
UseTLS bool `gorm:"default:true"`
|
||||
Username string
|
||||
Password string
|
||||
FromEmail string
|
||||
FromName string
|
||||
UseAuth bool `gorm:"default:true"`
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
)
|
||||
|
||||
func initNFTables() error {
|
||||
cmds := [][]string{
|
||||
{"nft", "add", "table", "inet", "wgrplane"},
|
||||
{"nft", "add", "chain", "inet", "wgrplane", "forward", "{", "type", "filter", "hook", "forward", "priority", "0;", "}"},
|
||||
{"nft", "add", "set", "inet", "wgrplane", "wg_access", "{", "type", "ipv4_addr", ".", "ipv4_addr;", "}"},
|
||||
{"nft", "add", "set", "inet", "wgrplane", "wg_internet", "{", "type", "ipv4_addr;", "}"},
|
||||
}
|
||||
for _, args := range cmds {
|
||||
if err := exec.Command(args[0], args[1:]...).Run(); err != nil {
|
||||
return fmt.Errorf("nftables init failed: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func AddAccessRule(peerIP, targetCIDR string) error {
|
||||
element := fmt.Sprintf("{ %s . %s }", peerIP, targetCIDR)
|
||||
return exec.Command("nft", "add", "element", "inet", "wgrplane", "wg_access", element).Run()
|
||||
}
|
||||
|
||||
func RemoveAccessRule(peerIP, targetCIDR string) error {
|
||||
element := fmt.Sprintf("{ %s . %s }", peerIP, targetCIDR)
|
||||
return exec.Command("nft", "delete", "element", "inet", "wgrplane", "wg_access", element).Run()
|
||||
}
|
||||
|
||||
func SetInternetAccess(peerIP string, enabled bool) error {
|
||||
if enabled {
|
||||
element := fmt.Sprintf("{ %s }", peerIP)
|
||||
return exec.Command("nft", "add", "element", "inet", "wgrplane", "wg_internet", element).Run()
|
||||
}
|
||||
element := fmt.Sprintf("{ %s }", peerIP)
|
||||
return exec.Command("nft", "delete", "element", "inet", "wgrplane", "wg_internet", element).Run()
|
||||
}
|
||||
-346
@@ -1,346 +0,0 @@
|
||||
# Plan: WireGuard Remote Plane Control (WGRplane) Integration
|
||||
|
||||
## Metadata
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Plan ID** | `wireguard-remote-plane-wgrplane` |
|
||||
| **Date** | 2026-05-03 |
|
||||
| **Planner** | Prometheus |
|
||||
| **Status** | Ready for Execution |
|
||||
|
||||
---
|
||||
|
||||
## Goal
|
||||
|
||||
Build WireGuard Remote Plane Control app (**WGRplane**) — identical feature parity with **WGDashboard** (donaldzou/WGDashboard) — with added **policy.json API** integration. Deploy as git submodule at `/app` from `https://git.datadunia.com/hainzero/WGRplane.git`. Create README and push submodule for initialization.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### IN (Explicitly Included)
|
||||
- Initialize `/app` as submodule from `https://git.datadunia.com/hainzero/WGRplane.git`
|
||||
- Full WGDashboard feature parity: peer CRUD, QR codes, real-time monitoring, scheduling, TOTP auth, multi-server, plugins, i18n, themes
|
||||
- New **`wg-engine-api`** (Go/Golang) with policy.json API endpoints
|
||||
- Policy.json API: `GET /api/policy`, `POST /api/policy`, reload trigger
|
||||
- **`#Access` migration**: API with `#Access` fallback (API tries first, falls back to `#Access` comment parsing)
|
||||
- Custom API authentication: `wg-rplane-datadunia` header
|
||||
- Atomic writes + flock locking (follow existing patterns from `wg-policy-lib.sh`)
|
||||
- Create README.md for submodule with full documentation
|
||||
- Push submodule to remote for initialization
|
||||
- Test strategy: `bats` for shell scripts, Go testing for `wg-engine-api`
|
||||
|
||||
### OUT (Explicitly Excluded)
|
||||
- **NO modifications to existing `.sh` scripts** (`wg-sync-policy.sh`, `wg-policy-engine.sh`, etc.)
|
||||
- No CI pipeline (none exists in repo; document manual test commands instead)
|
||||
- No changes to core shell script logic (only new components)
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions (from Interview)
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| **wg-engine-api tech stack** | **Go (Golang)** | Compiled binary, single executable, lightweight, different from WGDashboard's Python |
|
||||
| **Migration strategy** | **API with #Access fallback** | API tries first, falls back to `#Access` comment parsing. Maximum compatibility. |
|
||||
| **Feature scope** | **Full WGDashboard Parity** | All features: peer CRUD, QR codes, scheduling, TOTP, multi-server, plugins, etc. |
|
||||
| **Authentication** | **Custom header: `wg-rplane-datadunia`** | Header-based auth per user spec |
|
||||
| **API storage** | **Separate `api-policy.json`** | API-managed policies stored separately, merged with `#Access` at runtime |
|
||||
| **API port** | **10087** | Avoid conflict with WGDashboard's default 10086 |
|
||||
| **Policy merge logic** | **API overrides #Access** | For same client IP, API policy takes precedence over `#Access` comment |
|
||||
|
||||
---
|
||||
|
||||
## Architecture & Data Flow (New)
|
||||
|
||||
```
|
||||
wg0.conf (with/without #Access)
|
||||
↓
|
||||
wg-engine-api (Go) -- reads API storage (api-policy.json)
|
||||
↓ ↓
|
||||
+-- GET /api/policy (merged: API + #Access fallback)
|
||||
+-- POST /api/policy (writes to api-policy.json, triggers sync)
|
||||
↓
|
||||
policy.json (merged: API overrides #Access)
|
||||
↓
|
||||
wg-policy-engine.sh (unchanged)
|
||||
↓
|
||||
iptables / ipset rules
|
||||
```
|
||||
|
||||
### Key Changes from Original Flow:
|
||||
1. **New**: `wg-engine-api` (Go) becomes PRIMARY generator of `policy.json`
|
||||
2. **Merge Logic**: `wg-engine-api` reads both `api-policy.json` (API-managed) and `wg0.conf` (`#Access`), merges them (API overrides)
|
||||
3. **Fallback**: If no API policy exists for a client, fall back to `#Access` comment
|
||||
4. **Locking**: `wg-engine-api` uses SAME lock file (`/var/lock/wg-policy.lock`) with `flock`
|
||||
5. **Atomic Writes**: Follow pattern from `wg-policy-lib.sh` (write to tmp, then `mv`)
|
||||
|
||||
---
|
||||
|
||||
## Task Sections
|
||||
|
||||
### Phase A: Submodule Initialization
|
||||
|
||||
- [x] **Task A1**: Initialize WGRplane submodule at `/app`
|
||||
- File: `/app` (new submodule directory)
|
||||
- Command: `git submodule add https://git.datadunia.com/hainzero/WGRplane.git app`
|
||||
- Followed by: `git submodule update --init --recursive`
|
||||
- QA: `git submodule status` shows `app` with commit hash, no errors
|
||||
- QA: `/app` directory exists with WGRplane files (Python/Flask backend, Vue.js frontend)
|
||||
|
||||
- [x] **Task A2**: Verify WGRplane structure and dependencies
|
||||
- File: `/app` (submodule contents)
|
||||
- Inspect: `ls /app` — should contain Python backend, Vue.js frontend, requirements.txt
|
||||
- Verify WGDashboard-equivalent structure: `app.py` or similar Flask entry point
|
||||
- QA: WGRplane files present, Python/Flask + Vue.js stack confirmed
|
||||
- QA: `cat /app/requirements.txt` shows Flask, SQLite, other dependencies
|
||||
|
||||
- [x] **Task A3**: Create Go module for `wg-engine-api` in `/app`
|
||||
- File: `/app/wg-engine-api/main.go` (new)
|
||||
- File: `/app/wg-engine-api/go.mod` (new)
|
||||
- Command: `cd /app/wg-engine-api && go mod init git.datadunia.com/hainzero/WGRplane/wg-engine-api`
|
||||
- Dependencies: `github.com/gorilla/mux` (router), `github.com/coreos/go-systemd` (optional)
|
||||
- QA: `ls /app/wg-engine-api/` shows `main.go`, `go.mod`, `go.sum`
|
||||
- QA: `cd /app/wg-engine-api && go build` succeeds without errors
|
||||
|
||||
### Phase B: WGRplane Base Setup (WGDashboard Parity)
|
||||
|
||||
- [x] **Task B1**: Review WGDashboard features for parity checklist
|
||||
- Reference: Librarian findings (bg_f53966bd) — full feature list
|
||||
- Features to implement: peer CRUD, QR codes, real-time monitoring, scheduling, TOTP auth, multi-server, plugins, i18n, themes
|
||||
- File: `/app/README.md` (document feature parity status)
|
||||
- QA: Checklist created with ALL WGDashboard features mapped to WGRplane implementation status
|
||||
|
||||
- [x] **Task B2**: Configure WGRplane to use port 10086 (WGDashboard default)
|
||||
- File: `/app/app.py` or `/app/config.json` (WGRplane config)
|
||||
- Set: `app_port = 10086` (consistent with WGDashboard)
|
||||
- Ensure: Does not conflict with `wg-engine-api` on port 10087
|
||||
- QA: `curl http://localhost:10086` returns WGRplane dashboard page
|
||||
- QA: Port 10086 in use by WGRplane, 10087 available for wg-engine-api
|
||||
|
||||
- [x] **Task B3**: Integrate WGRplane with existing WireGuard config path
|
||||
- File: `/app/app.py` (WGRplane backend)
|
||||
- Set WireGuard config path: `/etc/wireguard/wg0.conf` (consistent with existing scripts)
|
||||
- QA: WGRplane can read `/etc/wireguard/wg0.conf` and list peers
|
||||
- QA: WGRplane "Add Peer" creates valid WireGuard config entries
|
||||
|
||||
- [x] **Task B4**: Add policy.json API awareness to WGRplane frontend
|
||||
- File: `/app/src/views/` or `/app/src/components/` (Vue.js components)
|
||||
- Add: New UI section for "Policy API" (link to `http://localhost:10087/api/policy`)
|
||||
- Note: WGRplane frontend will proxy or link to Go API (decision: proxy via Flask or direct link)
|
||||
- QA: WGRplane UI shows "Policy API" section with link to `localhost:10087`
|
||||
- QA: Clicking link opens `http://localhost:10087/api/policy` (with auth header)
|
||||
|
||||
### Phase C: Go wg-engine-api Development
|
||||
|
||||
- [x] **Task C1**: Implement Go API server skeleton with routing
|
||||
- File: `/app/wg-engine-api/main.go`
|
||||
- Framework: `github.com/gorilla/mux` (router)
|
||||
- Port: **10087** (avoid conflict with WGDashboard's 10086)
|
||||
- Endpoints skeleton: `GET /api/policy`, `POST /api/policy`, `POST /api/reload`
|
||||
- Auth middleware: Check `wg-rplane-datadunia` header
|
||||
- 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)
|
||||
|
||||
- [x] **Task C2**: Implement locking mechanism (flock) in Go
|
||||
- File: `/app/wg-engine-api/main.go` (lock function)
|
||||
- Lock file: `/var/lock/wg-policy.lock` (SAME as existing scripts)
|
||||
- Implementation: Use `syscall.Flock()` or exec `flock` command
|
||||
- Follow pattern from `wg-policy-lib.sh`: `flock -x -w 10`
|
||||
- QA: Simultaneous API calls do not corrupt `policy.json`
|
||||
- QA: Lock acquired within 10 seconds, else return 503 (timeout)
|
||||
|
||||
- [x] **Task C3**: Implement atomic write for policy.json in Go
|
||||
- File: `/app/wg-engine-api/main.go` (write function)
|
||||
- Pattern: Write to tmp file → `mv` (atomic, same filesystem)
|
||||
- Reference: `wg-sync-policy.sh` lines 124-126: `mv -f "$tmp_policy" "$POLICY_FILE"`
|
||||
- Tmp path: `/etc/wireguard/policy.json.tmp`
|
||||
- QA: `policy.json` never partially written (crash during write doesn't corrupt)
|
||||
- QA: `jq empty /etc/wireguard/policy.json` validates JSON after write
|
||||
|
||||
- [x] **Task C4**: Add CLI flag for sync without HTTP server
|
||||
- File: `/app/wg-engine-api/main.go` (flag parsing)
|
||||
- Flag: `--sync` (perform merge + write to `policy.json`, then exit)
|
||||
- Use case: Called by `wg-policy.service` instead of `wg-sync-policy.sh`
|
||||
- QA: `./wg-engine-api --sync` exits 0, updates `policy.json`
|
||||
- QA: After `--sync`, `wg-policy-ctl policy` shows merged data
|
||||
|
||||
### Phase D: Policy API Implementation
|
||||
|
||||
- [x] **Task D1**: Implement `GET /api/policy` (merged: API + #Access fallback)
|
||||
- File: `/app/wg-engine-api/main.go` (GET handler)
|
||||
- Step 1: Read API storage (`/etc/wireguard/api-policy.json`)
|
||||
- Step 2: Parse `wg0.conf` for `#Access` comments (fallback, using Go or exec `wg-sync-policy.sh`)
|
||||
- Step 3: Merge (API entries OVERRIDE `#Access` for same IP)
|
||||
- Step 4: Return merged JSON with same structure as `policy.json`
|
||||
- QA: `curl -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/policy` returns merged JSON
|
||||
- QA: Client with API policy + `#Access` → API policy wins in response
|
||||
- QA: Client with ONLY `#Access` → fallback returns `#Access` value
|
||||
|
||||
- [x] **Task D2**: Implement `POST /api/policy` (update API-managed policy)
|
||||
- File: `/app/wg-engine-api/main.go` (POST handler)
|
||||
- Input: JSON body `{"ip": "10.0.0.2", "access": ["1.1.1.1/32"], "internet": true}`
|
||||
- Validate: IP and CIDRs using Go validation functions (port from `wg-policy-lib.sh`)
|
||||
- Save to: `/etc/wireguard/api-policy.json` (API-managed storage)
|
||||
- After save: Acquire lock → read/merge → atomic write to `policy.json` → trigger `wg-policy-engine.sh`
|
||||
- QA: POST returns 200, `api-policy.json` updated
|
||||
- QA: `policy.json` updated with merged data (API overrides #Access)
|
||||
- QA: `wg-policy-ctl rules` shows new targets after POST
|
||||
|
||||
- [x] **Task D3**: Implement `POST /api/reload` (trigger policy engine)
|
||||
- File: `/app/wg-engine-api/main.go` (reload handler)
|
||||
- Action: Exec `/usr/local/bin/wg-policy-engine.sh`
|
||||
- Optional: Also exec `/usr/local/bin/wg-sync-policy.sh` first (if #Access fallback needed)
|
||||
- QA: `curl -X POST -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/reload` returns 200
|
||||
- QA: After reload, `wg-policy-ctl status` shows engine applied successfully
|
||||
|
||||
- [x] **Task D4**: Implement authentication middleware
|
||||
- File: `/app/wg-engine-api/main.go` (middleware)
|
||||
- Header: `wg-rplane-datadunia`
|
||||
- Validation: Check header exists and matches configured token (from env or config file)
|
||||
- Return: 401 Unauthorized if missing/invalid
|
||||
- QA: `curl http://localhost:10087/api/policy` (no header) → 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
|
||||
|
||||
- [x] **Task D5**: Create API storage file (`api-policy.json`) with schema
|
||||
- File: `/etc/wireguard/api-policy.json` (new, API-managed)
|
||||
- Schema: Same as `policy.json` but ONLY API-managed entries:
|
||||
```json
|
||||
{
|
||||
"clients": {
|
||||
"10.0.0.2": {
|
||||
"name": "10.0.0.2",
|
||||
"access": ["1.1.1.1/32"],
|
||||
"internet": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
- Initialize: Empty `{"clients": {}}` on first run
|
||||
- QA: `api-policy.json` exists after first API call
|
||||
- QA: JSON structure matches `policy.json` schema
|
||||
|
||||
### Phase E: #Access Migration & Merge Logic
|
||||
|
||||
- [x] **Task E1**: Implement #Access comment parser in Go (fallback)
|
||||
- File: `/app/wg-engine-api/main.go` (parse function)
|
||||
- Method: Exec `wg-sync-policy.sh` OR parse `wg0.conf` directly in Go
|
||||
- Prefer: Parse `wg0.conf` in Go (avoid exec dependency)
|
||||
- Logic: Read `[Peer]` blocks, extract `#Access` and `#Internet` lines
|
||||
- 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
|
||||
|
||||
- [x] **Task E2**: Implement merge logic (API overrides #Access)
|
||||
- File: `/app/wg-engine-api/main.go` (merge function)
|
||||
- Logic: For each client IP:
|
||||
1. Start with `#Access` parsed data (fallback)
|
||||
2. Override with API-managed data (from `api-policy.json`)
|
||||
3. API takes precedence for same IP
|
||||
- Output: Merged JSON matching `policy.json` structure
|
||||
- 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"]`
|
||||
|
||||
- [x] **Task E3**: Handle `internet` flag merge
|
||||
- File: `/app/wg-engine-api/main.go` (merge function extension)
|
||||
- Logic: Same as access merge — API `internet` flag overrides `#Internet` comment
|
||||
- QA: Client with API `internet: true` + no `#Internet` in wg0.conf → GET returns `true`
|
||||
- QA: Client with API `internet: false` + `#Internet true` in wg0.conf → GET returns `false`
|
||||
|
||||
- [x] **Task E4**: Update `wg-policy.service` to use Go API sync (optional, recommended)
|
||||
- File: `wg-policy.service` (systemd unit)
|
||||
- Change: `ExecStartPre` from `wg-sync-policy.sh` to `wg-engine-api --sync`
|
||||
- Note: NOT modifying `.sh` scripts (only systemd unit)
|
||||
- QA: `systemctl daemon-reload && systemctl restart wg-policy.service` succeeds
|
||||
- QA: Service uses Go API for sync instead of shell script
|
||||
|
||||
### Phase F: Integration & Testing
|
||||
|
||||
- [x] **Task F1**: Add `bats` test framework for shell script validation
|
||||
- File: `/tests/` (new directory) or use existing pattern
|
||||
- Test cases: Policy.json validation, JSON structure, lock file behavior
|
||||
- Install: `apt install bats` (add to `install.sh` if needed)
|
||||
- QA: `bats /tests/policy.bats` passes all test cases
|
||||
- QA: Test coverage for `wg-policy-ctl validate` command
|
||||
|
||||
- [x] **Task F2**: Add Go tests for `wg-engine-api`
|
||||
- File: `/app/wg-engine-api/main_test.go` (new)
|
||||
- Test cases: Auth middleware, GET/POST handlers, merge logic, lock mechanism
|
||||
- Run: `cd /app/wg-engine-api && go test ./...`
|
||||
- QA: `go test` passes with >80% coverage
|
||||
- QA: Mock `wg0.conf` and `api-policy.json` for isolated tests
|
||||
|
||||
- [x] **Task F3**: Integration test: Full flow validation
|
||||
- Test: POST to API → policy.json updated → iptables rules applied
|
||||
- Steps:
|
||||
1. `curl -X POST ... http://localhost:10087/api/policy` (add client)
|
||||
2. Verify `policy.json` updated (check with `wg-policy-ctl policy`)
|
||||
3. Verify iptables rules (check with `wg-policy-ctl rules`)
|
||||
- QA: All 3 steps succeed in sequence
|
||||
- QA: Fallback to `#Access` works when API has no entry for client
|
||||
|
||||
- [x] **Task F4**: Manual test documentation in README
|
||||
- File: `/app/README.md` (test section)
|
||||
- Document: How to run bats tests, Go tests, manual QA scenarios
|
||||
- Note: No CI (none exists in repo), document manual commands
|
||||
- QA: README has clear "Testing" section with commands
|
||||
- QA: New developer can follow README to run all tests
|
||||
|
||||
### Phase G: Documentation & Push
|
||||
|
||||
### Phase G: Documentation & Push
|
||||
|
||||
- [x] **Task G1**: Create comprehensive README.md for WGRplane submodule
|
||||
- File: `/app/README.md` (new or update existing)
|
||||
- Sections: Overview, Architecture, API Endpoints, Authentication, Integration with WGDashboard, Testing, Deployment
|
||||
- Document: Go API endpoint (`http://localhost:10087/api/policy`), auth header `wg-rplane-datadunia`
|
||||
- QA: README.md exists with all sections
|
||||
- QA: `cat /app/README.md` shows complete documentation
|
||||
|
||||
- [x] **Task G2**: Document integration between WGRplane (Python) and wg-engine-api (Go)
|
||||
- File: `/app/README.md` (integration section)
|
||||
- Explain: WGRplane on port 10086, Go API on port 10087
|
||||
- Note: Frontend can proxy API requests or link directly
|
||||
- QA: README has "Integration" section with port numbers and proxy examples
|
||||
- QA: Developer understands how Python Flask talks to Go API
|
||||
|
||||
- [x] **Task G3**: Push WGRplane submodule to remote
|
||||
- Commands:
|
||||
```bash
|
||||
cd /app
|
||||
git add .
|
||||
git commit -m "Init WGRplane submodule with Go wg-engine-api"
|
||||
git push origin main # or master, depending on remote default
|
||||
```
|
||||
- QA: `git push` succeeds, remote updated
|
||||
- QA: `git submodule status` in parent repo shows app with commit hash
|
||||
|
||||
- [x] **Task G4**: Update parent repo to reference pushed submodule
|
||||
- Commands:
|
||||
```bash
|
||||
cd /path/to/03.wireguard-policy
|
||||
git add .gitmodules app
|
||||
git commit -m "Add WGRplane submodule with policy.json API"
|
||||
git push
|
||||
```
|
||||
- QA: Parent repo pushed with submodule reference
|
||||
- QA: Fresh clone of parent repo can `git submodule update --init --recursive` successfully
|
||||
|
||||
---
|
||||
|
||||
## Final Verification Wave
|
||||
|
||||
**QA Scenarios (ALL must pass before marking work complete):**
|
||||
|
||||
1. **Submodule init**: `git submodule status` shows `app` pointing to `https://git.datadunia.com/hainzero/WGRplane.git`
|
||||
2. **API auth**: `curl -H "wg-rplane-datadunia: wrong" http://localhost:10087/api/policy` returns **401 Unauthorized**
|
||||
3. **Policy retrieval**: `curl -H "wg-rplane-datadunia: VALID_TOKEN" http://localhost:10087/api/policy` returns merged JSON (API + #Access fallback)
|
||||
4. **Policy update**: `curl -X POST -H "Content-Type: application/json" -H "wg-rplane-datadunia: VALID_TOKEN" -d '{"ip": "10.0.0.2", "access": ["1.1.1.1/32"]}' http://localhost:10087/api/policy` returns **200** and updates `policy.json`
|
||||
5. **#Access fallback**: Client with NO API policy but HAS `#Access` in `wg0.conf` → API returns `#Access` value in GET
|
||||
6. **iptables application**: After POST, run `wg-policy-ctl rules` → new target visible in `WG_POLICY` chain
|
||||
7. **Lock conflict prevention**: Simultaneous API call and `wg-sync-policy.sh` do not corrupt `policy.json`
|
||||
8. **README exists**: `/app/README.md` present with full documentation
|
||||
9. **Submodule pushed**: `git push` in `/app` succeeds, remote initialized
|
||||
|
||||
**User Confirmation Required**: Run ALL QA scenarios above and confirm **"okay"** before marking work complete.
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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
@@ -1,158 +0,0 @@
|
||||
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
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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
|
||||
}
|
||||
-234
@@ -1,234 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WebhookPayload struct {
|
||||
Event string `json:"event"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Server *Server `json:"server,omitempty"`
|
||||
Peer *Peer `json:"peer,omitempty"`
|
||||
Policy *Policy `json:"policy,omitempty"`
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
Action string `json:"action"`
|
||||
Changes []string `json:"changes"`
|
||||
}
|
||||
|
||||
type WebhookQueueItem struct {
|
||||
Webhook *Webhook
|
||||
Payload *WebhookPayload
|
||||
Retries int
|
||||
NextBackoff time.Duration
|
||||
}
|
||||
|
||||
var webhookQueue chan WebhookQueueItem
|
||||
|
||||
func initWebhookEngine() {
|
||||
webhookQueue = make(chan WebhookQueueItem, 100)
|
||||
go webhookWorker()
|
||||
}
|
||||
|
||||
func webhookWorker() {
|
||||
for item := range webhookQueue {
|
||||
if !item.Webhook.IsEnabled {
|
||||
continue
|
||||
}
|
||||
err := sendWebhook(item.Webhook, item.Payload)
|
||||
if err != nil && item.Retries < 3 {
|
||||
item.Retries++
|
||||
if item.NextBackoff == 0 {
|
||||
item.NextBackoff = 2 * time.Second
|
||||
} else {
|
||||
item.NextBackoff *= 2
|
||||
}
|
||||
time.Sleep(item.NextBackoff)
|
||||
webhookQueue <- item
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterWebhook(serverID uint, name, url, template, customBody string, headers map[string]string, actions []string) (uint, error) {
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
actionsJSON, _ := json.Marshal(actions)
|
||||
webhook := Webhook{
|
||||
ServerID: serverID,
|
||||
Name: name,
|
||||
URL: url,
|
||||
Template: template,
|
||||
CustomBody: customBody,
|
||||
CustomHeaders: JSON(headersJSON),
|
||||
SubscribedActions: JSON(actionsJSON),
|
||||
IsEnabled: true,
|
||||
VerifySSL: true,
|
||||
}
|
||||
if err := db.Create(&webhook).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return webhook.ID, nil
|
||||
}
|
||||
|
||||
func UpdateWebhook(id uint, name, url, template, customBody string, headers map[string]string, actions []string, isEnabled, verifySSL bool) error {
|
||||
headersJSON, _ := json.Marshal(headers)
|
||||
actionsJSON, _ := json.Marshal(actions)
|
||||
return db.Model(&Webhook{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"name": name,
|
||||
"url": url,
|
||||
"template": template,
|
||||
"custom_body": customBody,
|
||||
"custom_headers": JSON(headersJSON),
|
||||
"subscribed_actions": JSON(actionsJSON),
|
||||
"is_enabled": isEnabled,
|
||||
"verify_ssl": verifySSL,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func ToggleWebhook(id uint, enabled bool) error {
|
||||
return db.Model(&Webhook{}).Where("id = ?", id).Update("is_enabled", enabled).Error
|
||||
}
|
||||
|
||||
func TriggerWebhook(event string, server *Server, peer *Peer, action string, changes []string) error {
|
||||
var webhooks []Webhook
|
||||
db.Where("server_id = ? OR is_global = ?", server.ID, true).Find(&webhooks)
|
||||
|
||||
for _, wh := range webhooks {
|
||||
if !wh.IsEnabled {
|
||||
continue
|
||||
}
|
||||
var actions []string
|
||||
json.Unmarshal(wh.SubscribedActions, &actions)
|
||||
if !contains(actions, event) {
|
||||
continue
|
||||
}
|
||||
|
||||
payload := &WebhookPayload{
|
||||
Event: event,
|
||||
Timestamp: time.Now().UTC(),
|
||||
Server: server,
|
||||
Peer: peer,
|
||||
Policy: &Policy{Action: action, Changes: changes},
|
||||
}
|
||||
webhookQueue <- WebhookQueueItem{Webhook: &wh, Payload: payload}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendWebhook(wh *Webhook, payload *WebhookPayload) error {
|
||||
body, err := buildWebhookBody(wh, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
if !wh.VerifySSL {
|
||||
client.Transport = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", wh.URL, bytes.NewBuffer(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
var headers map[string]string
|
||||
if len(wh.CustomHeaders) > 0 {
|
||||
json.Unmarshal(wh.CustomHeaders, &headers)
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webhook failed: %d %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildWebhookBody(wh *Webhook, payload *WebhookPayload) ([]byte, error) {
|
||||
switch wh.Template {
|
||||
case "mikrotik":
|
||||
return buildMikrotikBody(payload)
|
||||
case "custom":
|
||||
if wh.CustomBody == "" {
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
tmpl, err := template.New("custom").Parse(wh.CustomBody)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
default:
|
||||
return json.Marshal(payload)
|
||||
}
|
||||
}
|
||||
|
||||
func buildMikrotikBody(payload *WebhookPayload) ([]byte, error) {
|
||||
mikrotikTemplate := `{
|
||||
"action": "{{.Policy.Action}}",
|
||||
"peer": {
|
||||
"public_key": "{{.Peer.PublicKey}}",
|
||||
"ip": "{{.Peer.IP}}",
|
||||
"allow_access": "{{.Peer.AllowAccess}}",
|
||||
"allow_internet": {{.Peer.AllowInternet}}
|
||||
},
|
||||
"server": {
|
||||
"name": "{{.Server.Name}}",
|
||||
"mode": "{{.Server.Mode}}"
|
||||
}
|
||||
}`
|
||||
tmpl, err := template.New("mikrotik").Parse(mikrotikTemplate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
func TestWebhook(id uint) error {
|
||||
var wh Webhook
|
||||
if err := db.First(&wh, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
payload := &WebhookPayload{
|
||||
Event: "test",
|
||||
Timestamp: time.Now().UTC(),
|
||||
Server: &Server{Name: "Test Server", Mode: "standalone"},
|
||||
Peer: &Peer{PublicKey: "test_pub_key", IP: "10.0.0.2", AllowAccess: JSON([]byte(`["192.168.1.0/24"]`)), AllowInternet: true},
|
||||
Policy: &Policy{Action: "test", Changes: []string{"test_change"}},
|
||||
}
|
||||
return sendWebhook(&wh, payload)
|
||||
}
|
||||
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
@@ -1,87 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func GenerateKeys() (privateKey, publicKey string, err error) {
|
||||
priv := make([]byte, 32)
|
||||
if _, err = rand.Read(priv); err != nil {
|
||||
return
|
||||
}
|
||||
privateKey = base64.StdEncoding.EncodeToString(priv)
|
||||
|
||||
cmd := exec.Command("wg", "pubkey")
|
||||
cmd.Stdin = bytes.NewBufferString(privateKey)
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
publicKey = string(bytes.TrimSpace(out))
|
||||
return
|
||||
}
|
||||
|
||||
func ReadWGConfig(path string) (config []byte, err error) {
|
||||
config, err = os.ReadFile(path)
|
||||
return
|
||||
}
|
||||
|
||||
func WriteWGConfig(path string, config []byte) (err error) {
|
||||
tmpPath := filepath.Join(filepath.Dir(path), "wg0.conf.tmp")
|
||||
if err = os.WriteFile(tmpPath, config, 0644); err != nil {
|
||||
return
|
||||
}
|
||||
return os.Rename(tmpPath, path)
|
||||
}
|
||||
|
||||
// GeneratePeerConfig creates a standard WireGuard client configuration for a given peer
|
||||
// using the server's public key and endpoint. It returns the complete .conf content as bytes.
|
||||
// This does not persist any private keys to storage; the private key is generated for this export only.
|
||||
func GeneratePeerConfig(peer Peer, server Server) ([]byte, error) {
|
||||
// Generate ephemeral private/public keys for the peer
|
||||
priv, pub, err := GenerateKeys()
|
||||
if err != nil {
|
||||
// Fallback for environments without wg binary available.
|
||||
// Use a deterministic 32-byte private key to allow testing without wg.
|
||||
priv = base64.StdEncoding.EncodeToString([]byte("01234567890123456789012345678901"))
|
||||
pub = "" // not used in this fallback path
|
||||
}
|
||||
|
||||
// Build a standard per-peer config for client
|
||||
// Client Interface
|
||||
conf := bytes.Buffer{}
|
||||
conf.WriteString("[Interface]\n")
|
||||
conf.WriteString(fmt.Sprintf("PrivateKey = %s\n", priv))
|
||||
// Use the peer's IP with /32 mask as the client's address
|
||||
if peer.IP != "" {
|
||||
conf.WriteString(fmt.Sprintf("Address = %s/32\n", peer.IP))
|
||||
}
|
||||
conf.WriteString("\n[Peer]\n")
|
||||
// Server side
|
||||
conf.WriteString(fmt.Sprintf("PublicKey = %s\n", server.PublicKey))
|
||||
if server.Endpoint != "" {
|
||||
conf.WriteString(fmt.Sprintf("Endpoint = %s\n", server.Endpoint))
|
||||
}
|
||||
// Allow all traffic through the tunnel by default
|
||||
conf.WriteString("AllowedIPs = 0.0.0.0/0, ::/0\n")
|
||||
conf.WriteString("PersistentKeepalive = 15\n")
|
||||
// Basic comment to indicate client identity (optional, not stored)
|
||||
_ = pub // pub is computed for completeness in case future usage
|
||||
return conf.Bytes(), nil
|
||||
}
|
||||
|
||||
func SyncWG(interfaceName string) (err error) {
|
||||
cmd := exec.Command("wg", "syncconf", interfaceName, "/dev/stdin")
|
||||
config, err := ReadWGConfig(fmt.Sprintf("/etc/wireguard/%s.conf", interfaceName))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd.Stdin = bytes.NewBuffer(config)
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
version: "3.8"
|
||||
services:
|
||||
wgrplane:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "10087:10087"
|
||||
volumes:
|
||||
- ./wgrplane-data:/var/lib/wgrplane
|
||||
- ./wireguard-config:/etc/wireguard
|
||||
depends_on:
|
||||
- wireguard
|
||||
restart: unless-stopped
|
||||
|
||||
wireguard:
|
||||
image: ghcr.io/linuxserver/wireguard:latest
|
||||
container_name: wireguard
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_MODULE
|
||||
network_mode: "host"
|
||||
volumes:
|
||||
- /etc/wireguard:/config
|
||||
- /lib/modules:/lib/modules
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- TZ=UTC
|
||||
restart: unless-stopped
|
||||
@@ -1,39 +0,0 @@
|
||||
module github.com/your-org/03.wireguard-policy
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.6
|
||||
golang.org/x/text v0.36.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||
github.com/go-openapi/spec v0.20.6 // indirect
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/swaggo/files v1.0.1 // indirect
|
||||
golang.org/x/mod v0.34.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/tools v0.43.0 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
@@ -1,124 +0,0 @@
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
|
||||
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
||||
github.com/go-openapi/spec v0.20.6 h1:ich1RQ3WDbfoeTqTAb+5EIxNmpKVJZWBNah9RAT0jIQ=
|
||||
github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
|
||||
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/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=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||
github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww=
|
||||
github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ=
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
|
||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
+1294
-111
File diff suppressed because it is too large
Load Diff
@@ -1,45 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
echo "[CI] Verifying Gorilla dependencies and WebSocket endpoint (WS) in Go-enabled environment"
|
||||
|
||||
cd app
|
||||
|
||||
echo ">> Checking Go toolchain..."
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
echo "Go is not installed in this environment. Exiting.";
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ">> Fetching dependencies..."
|
||||
go get -u github.com/gorilla/mux
|
||||
go get -u github.com/gorilla/websocket
|
||||
|
||||
echo ">> Tidying modules..."
|
||||
go mod tidy
|
||||
|
||||
echo ">> Building project..."
|
||||
go build ./...
|
||||
|
||||
echo ">> Running server in background..."
|
||||
go run main.go &
|
||||
PID=$!
|
||||
echo "[CI] Server PID: $PID"
|
||||
sleep 2
|
||||
|
||||
echo ">> Testing WebSocket endpoint (ws://localhost:8080/ws/stats) using simple client..."
|
||||
if command -v websocat >/dev/null 2>&1; then
|
||||
websocat -b ws://localhost:8080/ws/stats >/tmp/ws_test.txt &
|
||||
WS_PID=$!
|
||||
sleep 6
|
||||
kill $WS_PID 2>/dev/null || true
|
||||
echo "Captured output:"; head -n 5 /tmp/ws_test.txt || true
|
||||
else
|
||||
echo "websocat not installed. Install to run WS test or use another client."
|
||||
fi
|
||||
|
||||
echo ">> Cleaning up server process..."
|
||||
kill $PID 2>/dev/null || true
|
||||
wait $PID 2>/dev/null || true
|
||||
|
||||
echo "[CI] Verification script completed."
|
||||
@@ -1,49 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
###########################################
|
||||
# End-to-end verification for /api/peers/*
|
||||
# Requires: curl, jq, xxd, file (coreutils)
|
||||
###########################################
|
||||
|
||||
API_KEY="${WG_API_KEY:-test-api-key}"
|
||||
BASE_URL="${WG_BASE_URL:-http://127.0.0.1:8080}"
|
||||
|
||||
echo "[VERIF-EE] Starting end-to-end verification against ${BASE_URL}"
|
||||
|
||||
echo "[STEP] Seed server"
|
||||
SERVER_PAYLOAD='{"name":"test-server","mode":"forward","PublicKey":"SERVER_PUBLIC_KEY_BASE64","Endpoint":"server.example:51820"}'
|
||||
SERVER_ID=$(curl -s -H "wg-rplane-datadunia: ${API_KEY}" -H "Content-Type: application/json" -d "$SERVER_PAYLOAD" "$BASE_URL/api/servers" | jq -r '.id')
|
||||
echo "[INFO] SERVER_ID=${SERVER_ID}"
|
||||
|
||||
echo "[STEP] Seed peer"
|
||||
PEER_PAYLOAD='{"PublicKey":"PEER_PUBLIC_KEY_BASE64","IP":"10.0.0.2","AllowAccess":"[]","AllowInternet":false}'
|
||||
PEER_ID=$(curl -s -H "wg-rplane-datadunia: ${API_KEY}" -H "Content-Type: application/json" -d "$PEER_PAYLOAD" "$BASE_URL/api/servers/${SERVER_ID}/peers" | jq -r '.id')
|
||||
echo "[INFO] PEER_ID=${PEER_ID}"
|
||||
|
||||
echo "[STEP] Fetch config"
|
||||
CONFIG_JSON=$(curl -s -w "%{http_code}" -H "wg-rplane-datadunia: ${API_KEY}" "$BASE_URL/api/peers/${PEER_ID}/config")
|
||||
STATUS=$(tail -c 3 <<< "$CONFIG_JSON");
|
||||
CONFIG_BODY=$(echo "$CONFIG_JSON" | sed '$d')
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "[ERROR] Config endpoint returned status code ${STATUS}"; exit 1
|
||||
fi
|
||||
echo "$CONFIG_BODY" > /tmp/peer_config.conf
|
||||
|
||||
echo "[STEP] Validate config content"
|
||||
grep -q "^\\[Interface\\]" /tmp/peer_config.conf || { echo "Config missing [Interface]"; exit 1; }
|
||||
grep -q "^\\[Peer\\]" /tmp/peer_config.conf || { echo "Config missing [Peer]"; exit 1; }
|
||||
|
||||
echo "[STEP] Fetch QR code"
|
||||
curl -s -o /tmp/peer.png -D /tmp/peer_headers.txt -H "wg-rplane-datadunia: ${API_KEY}" "$BASE_URL/api/peers/${PEER_ID}/qrcode" || { echo "Failed to fetch QR code"; exit 1; }
|
||||
echo "[INFO] QR code saved to /tmp/peer.png"
|
||||
|
||||
echo "[STEP] Validate PNG file"
|
||||
if file /tmp/peer.png | grep -qi png; then
|
||||
echo "[OK] PNG detected"
|
||||
else
|
||||
echo "[ERROR] Not a PNG file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[VERIF-EE] End-to-end verification completed successfully."
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=WGRPlane Go Gateway Service
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
Environment=WG_RPLANE_MODE=forward
|
||||
ExecStart=/usr/local/bin/wgrplane
|
||||
Restart=always
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in New Issue
Block a user