80 Commits

Author SHA1 Message Date
datadunia b5c9b180dc feat: migrate policy firewall from iptables/ipset to nftables
- Rewrite wg-policy-lib.sh: replace ipset functions with nft helpers, add backup_nftables()
- Rewrite wg-policy-engine.sh: generate nft ruleset file, atomic load via nft -f, rollback support
- Simplify wg-policy-cleanup.sh: single nft delete table inet wg_policy
- Update wg-policy-ctl: nft commands for rules/ipset/stats/backup
- Rebuild install.sh: nftables dependency, WireGuard pre-check, PostUp/PostDown auto-integration
- Update build.sh/build.bat: match install.sh changes
- Update README.md: nftables prerequisites and references
2026-06-21 15:02:48 +07:00
datadunia eb525879f1 fix: test build
Beta Release / deploy (push) Successful in 1m2s
2026-05-09 15:19:03 +07:00
datadunia b1c7f10fec fix: test build
Beta Release / deploy (push) Successful in 1m4s
2026-05-09 14:41:26 +07:00
datadunia da09fb2e65 fix: test build
Beta Release / deploy (push) Successful in 1m3s
2026-05-09 14:34:26 +07:00
datadunia fefaf49298 fix: test build
Beta Release / deploy (push) Successful in 1m1s
2026-05-09 13:50:03 +07:00
datadunia fc05ac0725 fix: test build
Beta Release / deploy (push) Failing after 20s
2026-05-09 05:54:28 +07:00
datadunia 5bcac141d1 fix: test build
Beta Release / deploy (push) Successful in 1m4s
2026-05-09 03:48:48 +07:00
datadunia c7ad30d2df fix: test build
Beta Release / deploy (push) Successful in 1m4s
2026-05-09 02:42:52 +07:00
datadunia 93f6aaea76 feat: check and delete existing release before creating a new one
Beta Release / deploy (push) Successful in 1m5s
2026-05-09 01:57:11 +07:00
datadunia 7a448ffa3a fix: test build
Beta Release / deploy (push) Successful in 58s
2026-05-09 01:47:14 +07:00
datadunia 89443f9481 fix: test build
Beta Release / deploy (push) Successful in 49s
2026-05-09 01:27:45 +07:00
datadunia c5d3651a96 fix: test build
Beta Release / deploy (push) Failing after 1m0s
2026-05-09 01:22:33 +07:00
datadunia 548e3949ad feat: test build
Beta Release / deploy (push) Failing after 35s
2026-05-09 01:00:59 +07:00
datadunia 4b8d5008d6 refactor: extract shared CI steps to deploy_call.yaml reusable workflow
Beta Release / deploy (push) Successful in 50s
Release / deploy (push) Successful in 59s
2026-05-08 05:51:48 +07:00
datadunia 25e0cfd15e refactor: extract shared CI steps to deploy_call.yaml reusable workflow
Release / deploy (push) Has been cancelled
Beta Release / deploy (push) Has been cancelled
2026-05-08 05:48:02 +07:00
datadunia 9c771f098c refactor: extract shared CI steps to deploy_call.yaml reusable workflow
Beta Release / deploy (push) Successful in 59s
Release / deploy (push) Failing after 56s
2026-05-08 05:41:06 +07:00
datadunia dfc8d9ed57 fix: restore Create Release step with correct Swagger-compliant JSON and auth header
Release / build-and-release (push) Successful in 1m0s
2026-05-08 05:04:54 +07:00
datadunia 565d7f1a35 fix: restore Create Release step with correct Swagger-compliant JSON and auth header
Release / build-and-release (push) Failing after 54s
2026-05-08 05:00:55 +07:00
datadunia 45c7788d1d fix: restore Create Release step with correct Swagger-compliant JSON and auth header
Release / build-and-release (push) Failing after 52s
2026-05-08 04:55:29 +07:00
datadunia b9fbe1430f fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 04:30:35 +07:00
datadunia 5eec7651da fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 04:26:27 +07:00
datadunia c8cf771bfa fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 59s
2026-05-08 04:22:21 +07:00
datadunia ba7b9ac64c fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 04:19:51 +07:00
datadunia c42683e968 fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 56s
2026-05-08 04:14:57 +07:00
datadunia 4a3d099ff9 fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 56s
2026-05-08 04:03:52 +07:00
datadunia 64e8372fc1 fix: use token query param and form-data upload per Gitea API spec
Release / build-and-release (push) Failing after 58s
2026-05-08 03:17:40 +07:00
datadunia d7b1de1885 fix: use curl -u x-token auth for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 03:10:03 +07:00
datadunia 7d0c0c18d0 fix: use curl for Gitea release API instead of broken action
Release / build-and-release (push) Failing after 55s
2026-05-08 03:02:30 +07:00
datadunia 8d237b293f fix: Action TOKEN
Release / build-and-release (push) Failing after 55s
2026-05-08 02:49:43 +07:00
datadunia 43477a3333 fix: install jq if missing, restore jq for changelog in latest.json
Release / build-and-release (push) Failing after 1m10s
2026-05-08 02:20:55 +07:00
datadunia 42a35302b3 fix: pass token via env GITHUB_TOKEN and GITEA_TOKEN for release action
Release / build-and-release (push) Failing after 54s
2026-05-08 02:18:01 +07:00
datadunia 6d9fa80b54 update app submodule
Release / build-and-release (push) Failing after 55s
2026-05-08 02:05:13 +07:00
datadunia a59ebd4813 fix: Action TOKEN 2026-05-08 02:01:46 +07:00
datadunia 0a7bf72d6c fix: use akkuman/gitea-release-action from GitHub with server_url
Release / build-and-release (push) Failing after 56s
2026-05-08 01:52:48 +07:00
datadunia 861d910363 fix: use gitea-release-action for native Gitea release support
Release / build-and-release (push) Failing after 4s
2026-05-08 01:47:49 +07:00
datadunia d0d529450c fix: use softprops/action-gh-release and add cleanup step
Release / build-and-release (push) Failing after 1m16s
2026-05-08 01:35:05 +07:00
datadunia 0c6bac4c22 fix: use tag refs instead of commit SHA for all actions
Release / build (amd64) (push) Successful in 51s
Release / build (arm64) (push) Successful in 44s
2026-05-08 00:17:23 +07:00
datadunia 7b02527200 fix: downgrade upload-artifact v4 to v3 for Gitea compatibility
Release / build (amd64) (push) Failing after 56s
Release / build (arm64) (push) Failing after 4s
2026-05-08 00:06:16 +07:00
datadunia fa4bf318b6 chore: comment out debug submodule step
Release / build (amd64) (push) Failing after 41s
Release / build (arm64) (push) Failing after 37s
2026-05-07 23:19:56 +07:00
datadunia cdd22860b3 fix: remove generate-latest-json job, update app submodule
Release / build (amd64) (push) Failing after 2m23s
Release / build (arm64) (push) Failing after 1m3s
2026-05-07 22:30:03 +07:00
datadunia bbae401002 fix: Action TOKEN
Release / build (amd64) (push) Failing after 30s
Release / build (arm64) (push) Failing after 23s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:21:50 +07:00
datadunia fb198f64bb fix: Action TOKEN
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:18:52 +07:00
datadunia d62936d607 fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:16:54 +07:00
datadunia 3b0f478d3a fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:14:10 +07:00
datadunia b39d7a4bd2 fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:07:54 +07:00
datadunia 81af99dfc4 fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:05:04 +07:00
datadunia 139463855d fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:02:45 +07:00
datadunia 309a3fea8f fix: Action TOKEN
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 13:52:59 +07:00
datadunia 1fdb8135db fix: shallow
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 13:45:26 +07:00
datadunia 22b9133431 fix: shallow
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 13:43:36 +07:00
datadunia 6e9d389cbf fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 12:53:35 +07:00
datadunia 644c6e2dde fix: shallow 2026-05-07 12:51:55 +07:00
datadunia 1716747c09 fix: shallow
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 12:48:27 +07:00
datadunia 47abc63658 fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 11:41:42 +07:00
datadunia 44c480a89a fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 11:10:14 +07:00
datadunia 35dcc2f15f fix: shallow 2026-05-07 11:08:53 +07:00
datadunia f71ef8c3e4 fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 06:23:02 +07:00
datadunia 6308017b24 fix: shallow
Release / build (amd64) (push) Failing after 4s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-07 06:20:45 +07:00
datadunia 5b4f91e6e0 fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 06:16:39 +07:00
datadunia f6af1fb6d8 fix: shallow 2026-05-07 05:16:39 +07:00
datadunia f47b50b857 fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-07 05:10:19 +07:00
datadunia e2c807dc12 fix: shallow
Release / build (amd64) (push) Failing after 13s
Release / build (arm64) (push) Failing after 9s
2026-05-07 04:35:06 +07:00
datadunia 144be82e9b fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 04:32:28 +07:00
datadunia 0feed113ac fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 04:19:49 +07:00
datadunia 20478191a3 fix: shallow
Release / build (amd64) (push) Failing after 8s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 02:17:39 +07:00
datadunia 32f1e78ee1 fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 02:16:24 +07:00
datadunia ac32038ebb fix: shallow clone with timeout to prevent hang
Release / build (amd64) (push) Failing after 8s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 02:14:26 +07:00
datadunia 417fd99dbf fix: use repo-local http.extraHeader for submodule auth
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 01:52:21 +07:00
datadunia 1633fede7a fix: manual clone with http.extraheader reset for host mode
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-07 01:12:45 +07:00
datadunia 14742b4c27 fix: use submodules: true for checkout
Release / build (amd64) (push) Failing after 33s
Release / build (arm64) (push) Failing after 47s
Release / generate-latest-json (push) Has been skipped
2026-05-06 23:43:51 +07:00
datadunia c49964d4d4 fix: use SSH for submodule auth 2026-05-06 17:52:25 +07:00
datadunia 4c00af7c44 fix: checkout with submodules false + manual auth
Release / build (amd64) (push) Failing after 41s
Release / build (arm64) (push) Failing after 33s
Release / generate-latest-json (push) Has been skipped
2026-05-06 17:33:20 +07:00
datadunia de21d422b6 fix: use git pull + submodule update instead of actions/checkout
Release / build (amd64) (push) Failing after 4s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-06 17:20:49 +07:00
datadunia 581350971a fix: use secrets for submodule auth
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-06 16:40:09 +07:00
datadunia 8a8927c8e8 fix: submodule checkout with token auth
Release / build (amd64) (push) Failing after 7s
Release / generate-latest-json (push) Has been skipped
Release / build (arm64) (push) Failing after 6s
2026-05-06 16:21:55 +07:00
datadunia 282b2a3b69 fix: properly initialize submodules in workflow
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 7s
Release / generate-latest-json (push) Has been skipped
2026-05-06 16:15:16 +07:00
datadunia fc1e5513aa workflow fix 2026-05-06 16:05:05 +07:00
datadunia 481807769e feat: add Gitea CI/CD workflows and update documentation
Release / build (amd64) (push) Failing after 10s
Release / build (arm64) (push) Failing after 11s
Release / generate-latest-json (push) Has been skipped
- Add .gitea/workflows/release.yaml for multi-arch build (amd64, arm64)
- Generate latest.json for auto-update system
- Update AGENTS.md and README.md for WGRplane integration
- Update app/ submodule reference to latest commit (2db8e5a)
2026-05-06 05:27:58 +07:00
datadunia 52e629ac02 docs: update AGENTS.md and README.md for WGRplane integration
- Add WGRplane component documentation to AGENTS.md
- Update README.md with combined WireGuard Policy + WGRplane docs
- Fix .gitignore (remove .gitea/ from ignore)
2026-05-06 05:23:00 +07:00
datadunia b3676b87cb feat: update WGRplane submodule to latest version
- Update app/ submodule to commit ff80d9c
- Complete WGRplane with Vue.js frontend and Go backend
- Includes Glassmorphism UI, webhooks, i18n, and plugins
2026-05-05 03:35:52 +07:00
92 changed files with 2172 additions and 11730 deletions
+15
View File
@@ -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
+136
View File
@@ -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"
+14
View File
@@ -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
View File
@@ -1,3 +1,2 @@
.test/ .test/
wgrplane
.sisyphus/ .sisyphus/
+3
View File
@@ -0,0 +1,3 @@
[submodule "app"]
path = app
url = https://git.datadunia.com/hainzero/WGRplane.git
+111 -22
View File
@@ -1,27 +1,116 @@
# WireGuard Policy Firewall (`03.wireguard-policy`) # AGENTS.md -- WireGuard Policy Firewall + WGRplane
## Architecture & Configuration Flow **Generated:** 2026-05-08
- **Goal:** Dynamic iptables/ipset rules based on WireGuard configuration (`wg0.conf`). **Project:** 03.wireguard-policy
- **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 ## OVERVIEW
- **Target IPs Parsing (`#Access`):** The firewall script uses the custom `#Access` comment in `wg0.conf` to define egress/firewall whitelists for clients. 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.
- **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 ## STRUCTURE
- `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. 03.wireguard-policy/
- `wg-policy-ctl rules`: View the applied iptables rules in the active chain (`WG_POLICY`). ├── app/ # WGRplane: Go backend + Vue 3 frontend (see app/AGENTS.md)
- `wg-policy-ctl reload`: Forces a re-sync from `wg0.conf` and re-applies iptables. │ ├── *.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 ## WHERE TO LOOK
- **Atomic Operations:** Always use atomic writes (`mv -f tmp target`) for `policy.json` to prevent the policy engine from reading partial files. | Task | Location |
- **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. | Policy firewall logic | `wg-sync-policy.sh`, `wg-policy-engine.sh`, `wg-policy-lib.sh` |
- **Dependencies:** Requires `jq` and `inotify-tools`. | 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 ## CRITICAL DESIGN RULES
- Restart the watcher service: `systemctl restart wg-policy.service`
- Check service logs: `journalctl -u wg-policy.service -f` ### 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
View File
@@ -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"]
+312 -371
View File
@@ -1,139 +1,349 @@
# 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 nftables 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 App)
|-------|-------------| - [WGRplane Overview](#-wgrplane-overview)
| **WGRplane (Go + Vue 3)** | Web dashboard and REST API for managing WireGuard servers, peers, webhooks, SMTP, and scheduling. | - [WGRplane Architecture](#-wgrplane-architecture)
| **Policy Firewall (Shell)** | Dynamic iptables/ipset engine that reads `#Access` comments from `wg0.conf` to enforce per-peer egress rules. | - [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 nftables 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` |
| `nftables` | **Yes** | `apt install nftables` |
---
## ⚙️ 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 `nft` 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 nftables rules
wg-policy-ctl rules
# Check nftables set contents
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 ### 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. - **`forward`** - Directly applies nftables rules on the local machine. Peer policies are enforced 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. - **`standalone`** - Acts as a control plane that triggers webhooks to remote WireGuard servers. Policy enforcement happens on the remote side.
### 2-Column Policy ### 2-Column Policy
Each peer has two independent policy columns: Each peer has two independent policy columns:
| Column | Purpose | | Column | Function |
|--------|---------| |--------|----------|
| **AllowAccess** | List of CIDRs the peer can reach (internal targets). | | **AllowAccess** | List of CIDRs the peer can access (internal targets) |
| **AllowInternet** | Boolean flag. When `true`, the peer gets unrestricted internet egress. | | **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 - **Go-Native Architecture**: Single Go binary handles all API, database, webhooks, scheduler, and nftables. No Python/Flask needed.
graph TB - **Complete Peer CRUD**: Add, edit, delete peers. Generate QR codes for mobile client import. Export `.conf` configuration files.
subgraph "WGRplane Dashboard" - **Hybrid Mode**: `forward` mode (local nftables) or `standalone` mode (webhook to remote servers).
A[Vue 3 Frontend] -->|HTTP/WebSocket| B[Go REST API :10087] - **2-Column Policy UI**: "Allow Access" column (firewall whitelist CIDR) and "Allow Internet" toggle per peer.
end - **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" ## 🛠 WGRplane Tech Stack
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
| Component | Technology | | 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 | | **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 | | **Webhooks** | Go net/http with retry + exponential backoff |
| **Scheduling** | robfig/cron v3 | | **Scheduling** | robfig/cron v3 |
| **Firewall** | Bash, iptables, ipset, nftables, inotify-tools, jq | | **QR Code** | skip2/go-qrcode |
| **Email** | jordan-wright/email (SMTP) |
| **Firewall** | Bash, nftables, inotify-tools, jq |
| **Container** | Docker (multi-stage build), docker-compose | | **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) ### Option 1: Docker Compose (Recommended)
```bash ```bash
# Clone the repository # Clone repository
git clone https://git.datadunia.com/hainzero/WGRplane.git git clone https://git.datadunia.com/hainzero/WGRplane.git
cd 03.wireguard-policy cd 03.wireguard-policy
# Start both WGRplane and WireGuard # Start WGRplane and WireGuard
docker compose up -d docker compose up -d
# Access the dashboard # Access dashboard at http://localhost:10087
# 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 ### Option 2: Install Script
```bash ```bash
# Run the automated installer (Ubuntu/Debian/CentOS) # Run automated installer (Ubuntu/Debian/CentOS)
sudo ./install.sh install sudo ./install.sh install
# Uninstall # Uninstall
sudo ./install.sh uninstall sudo ./install.sh uninstall
``` ```
The install script handles Docker installation, repository cloning, `.env` creation, and service startup.
### Option 3: Manual Build ### Option 3: Manual Build
```bash ```bash
# Build the Go binary # Build Go binary
cd app cd app
go build -o ../wgrplane . go build -o ../wgrplane .
cd .. cd ..
# Build the frontend # Build frontend
cd app/frontend cd app/frontend
npm install && npm run build npm install && npm run build
cd ../.. cd ../..
@@ -143,333 +353,64 @@ cd ../..
# Server starts on :10087 # 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 ```bash
# Install the service file # Check system dependencies first
sudo cp wgrplane.service /etc/systemd/system/ ./wgrplane doctor
sudo systemctl daemon-reload
sudo systemctl enable wgrplane.service # Install as systemd service (auto-creates unit, enables, and starts)
sudo systemctl start wgrplane.service sudo ./wgrplane install
# View logs # 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 To run the server manually in the foreground with custom ports:
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`
```bash ```bash
wg-policy-ctl status # Health check, lock status, rule counts ./wgrplane serve --port 8080 --host 127.0.0.1
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
``` ```
### Prerequisites On first run, it generates a default configuration file at `~/.config/wgrplane/config.json`.
| 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.
--- ---
## Scheduler ## 📁 Project Structure
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
``` ```
03.wireguard-policy/ 03.wireguard-policy/
├── app/ # Go backend + Vue frontend ├── app/ # Go backend + Vue frontend
│ ├── main.go # Bootstrap server, routing, init DB
│ ├── handlers.go # REST API route handlers │ ├── handlers.go # REST API route handlers
│ ├── main.go # Server bootstrap, routes
│ ├── models.go # GORM models (Server, Peer, Webhook, SMTP) │ ├── models.go # GORM models (Server, Peer, Webhook, SMTP)
│ ├── auth.go # JWT, TOTP, API key auth middleware │ ├── 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 │ ├── webhook.go # Webhook engine with retry/backoff
│ ├── scheduler.go # Cron jobs (expiry, data limit, reset) │ ├── scheduler.go # Cron jobs (expiry, data limit, reset)
│ ├── stats.go # WebSocket Hub for real-time stats │ ├── 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) │ ├── frontend/ # Vue 3 SPA (TypeScript, TailwindCSS)
│ └── active.{en,id,zh}.json # Translation files │ └── docs/ # Swagger documentation
├── wg-sync-policy.sh # Parses wg0.conf → policy.json ├── wg-sync-policy.sh # Parse wg0.conf → policy.json
├── wg-policy-engine.sh # Applies policy.json → iptables/ipset ├── wg-policy-engine.sh # Apply policy.json → nftables
├── wg-sync-watch.sh # inotifywait watcher daemon ├── 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-cleanup.sh # Cleanup script for PostDown
├── wg-policy.service # Systemd unit for watcher daemon ├── wg-policy.service # Systemd unit for watcher daemon
├── wgrplane.service # Systemd unit for Go backend ├── wgrplane.service # Systemd unit for Go backend
├── install.sh # Automated installer (Docker + services) ├── install.sh # Automated installer (Docker + services)
├── Dockerfile # Multi-stage Docker build ├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose stack ├── docker-compose.yml # Docker Compose stack
├── build.sh / build.bat # Installer rebuild scripts
└── README.md # This file └── README.md # This file
``` ```
--- For detailed WGRplane documentation including webhooks, plugins, scheduler, and frontend details, refer to [`app/README.md`](app/README.md).
## License
This project builds upon concepts from WGDashboard (donaldzou/WGDashboard) with modifications for policy.json API integration and dynamic firewall enforcement.
Submodule
+1
Submodule app added at b43866d42c
-27
View File
@@ -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`
-15
View File
@@ -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
View File
@@ -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.
-4
View File
@@ -1,4 +0,0 @@
{
"greeting": "Hello",
"farewell": "Goodbye"
}
-4
View File
@@ -1,4 +0,0 @@
{
"greeting": "Halo",
"farewell": "Selamat tinggal"
}
-4
View File
@@ -1,4 +0,0 @@
{
"greeting": "你好",
"farewell": "再见"
}
-146
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-777
View File
@@ -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
View File
@@ -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)
}
-24
View File
@@ -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?
-13
View File
@@ -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>
-1885
View File
File diff suppressed because it is too large Load Diff
-30
View File
@@ -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"
}
}
-6
View File
@@ -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

-24
View File
@@ -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

-161
View File
@@ -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
View File
@@ -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"
>
&times;
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps<{
modelValue: string[]
placeholder?: string
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const inputValue = ref('')
const error = ref('')
const validateCIDR = (cidr: string): boolean => {
const cidrRegex = /^(\d{1,3}\.){3}\d{1,3}\/(\d{1,2})$/
if (!cidrRegex.test(cidr)) {
error.value = 'Invalid CIDR format. Use e.g., 192.168.1.0/24'
return false
}
const [ip, prefixStr] = cidr.split('/')
const octets = ip.split('.').map(Number)
const prefix = Number(prefixStr)
if (octets.some(octet => octet < 0 || octet > 255)) {
error.value = 'Invalid IP octet (must be 0-255)'
return false
}
if (prefix < 0 || prefix > 32) {
error.value = 'Invalid prefix (must be 0-32)'
return false
}
error.value = ''
return true
}
const addCIDR = () => {
const trimmed = inputValue.value.trim()
if (!trimmed) return
if (props.modelValue.includes(trimmed)) {
error.value = 'CIDR already exists'
return
}
if (validateCIDR(trimmed)) {
emit('update:modelValue', [...props.modelValue, trimmed])
inputValue.value = ''
error.value = ''
}
}
const removeCIDR = (index: number) => {
const newList = [...props.modelValue]
newList.splice(index, 1)
emit('update:modelValue', newList)
}
</script>
@@ -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>
-402
View File
@@ -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>
-127
View File
@@ -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>
-36
View File
@@ -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
}
}
-9
View File
@@ -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)
}
-15
View File
@@ -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
}
})
-29
View File
@@ -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"
}
-29
View File
@@ -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"
}
-29
View File
@@ -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": "保存设置"
}
-10
View File
@@ -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')
-45
View File
@@ -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
-5
View File
@@ -1,5 +0,0 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
-13
View File
@@ -1,13 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
background: #0f172a;
color: #fff;
}
#app {
min-height: 100vh;
}
-47
View File
@@ -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'
}
]
-188
View File
@@ -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>
-20
View File
@@ -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>
-222
View File
@@ -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>
-165
View File
@@ -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>
-20
View File
@@ -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'),
],
}
-23
View File
@@ -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"]
}
-6
View File
@@ -1,6 +0,0 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
})
-53
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"`
}
-40
View File
@@ -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
View File
@@ -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.
-55
View File
@@ -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)
}
}
-95
View File
@@ -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
View File
@@ -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
}
}()
}
-42
View File
@@ -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
View File
@@ -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
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
-87
View File
@@ -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()
}
+71 -1
View File
@@ -16,12 +16,78 @@ echo Building %INSTALL_SCRIPT%...
>> "%INSTALL_SCRIPT%" echo exit 1 >> "%INSTALL_SCRIPT%" echo exit 1
>> "%INSTALL_SCRIPT%" echo fi >> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if ! command -v wg ^&>/dev/null; then
>> "%INSTALL_SCRIPT%" echo echo "[ERROR] WireGuard is not installed."
>> "%INSTALL_SCRIPT%" echo echo " Debian/Ubuntu: apt install wireguard"
>> "%INSTALL_SCRIPT%" echo echo " RHEL/CentOS: dnf install wireguard-tools"
>> "%INSTALL_SCRIPT%" echo echo " Arch: pacman -S wireguard-tools"
>> "%INSTALL_SCRIPT%" echo exit 1
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if [[ ! -f /etc/wireguard/wg0.conf ]]; then
>> "%INSTALL_SCRIPT%" echo echo "[WARN] WireGuard config not found: /etc/wireguard/wg0.conf"
>> "%INSTALL_SCRIPT%" echo echo " PostUp/PostDown hooks will not be added automatically."
>> "%INSTALL_SCRIPT%" echo echo " Create your wg0.conf first, then re-install."
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo WG_CONF="/etc/wireguard/wg0.conf"
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo install_hooks^(^) {
>> "%INSTALL_SCRIPT%" echo if [[ ! -f "$WG_CONF" ]]; then
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if grep -q "wg-sync-policy.sh" "$WG_CONF" 2^>/dev/null; then
>> "%INSTALL_SCRIPT%" echo echo "[OK] PostUp/PostDown hooks already present in $WG_CONF"
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Adding PostUp/PostDown hooks to $WG_CONF..."
>> "%INSTALL_SCRIPT%" echo cp "$WG_CONF" "${WG_CONF}.bak.$(date +%%Y%%m%%d%%H%%M%%S)"
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo local peer_line
>> "%INSTALL_SCRIPT%" echo peer_line=$(grep -n '^\[Peer\]' "$WG_CONF" ^| head -1 ^| cut -d: -f1)
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if [[ -n "$peer_line" ]]; then
>> "%INSTALL_SCRIPT%" echo sed -i "${peer_line}i\\
>> "%INSTALL_SCRIPT%" echo # WireGuard Policy Firewall hooks\\
>> "%INSTALL_SCRIPT%" echo PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh\\
>> "%INSTALL_SCRIPT%" echo PostDown = /usr/local/bin/wg-policy-cleanup.sh" "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo else
>> "%INSTALL_SCRIPT%" echo {
>> "%INSTALL_SCRIPT%" echo echo ""
>> "%INSTALL_SCRIPT%" echo echo "# WireGuard Policy Firewall hooks"
>> "%INSTALL_SCRIPT%" echo echo "PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh"
>> "%INSTALL_SCRIPT%" echo echo "PostDown = /usr/local/bin/wg-policy-cleanup.sh"
>> "%INSTALL_SCRIPT%" echo } ^>^> "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "[OK] Hooks added to $WG_CONF"
>> "%INSTALL_SCRIPT%" echo }
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo remove_hooks^(^) {
>> "%INSTALL_SCRIPT%" echo if [[ ! -f "$WG_CONF" ]]; then
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if ! grep -q "wg-sync-policy.sh" "$WG_CONF" 2^>/dev/null; then
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Removing PostUp/PostDown hooks from $WG_CONF..."
>> "%INSTALL_SCRIPT%" echo cp "$WG_CONF" "${WG_CONF}.bak.$(date +%%Y%%m%%d%%H%%M%%S)"
>> "%INSTALL_SCRIPT%" echo sed -i '/# WireGuard Policy Firewall hooks/d' "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo sed -i '/wg-sync-policy\.sh/d' "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo sed -i '/wg-policy-cleanup\.sh/d' "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo echo "[OK] Hooks removed from $WG_CONF"
>> "%INSTALL_SCRIPT%" echo }
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo install_policy^(^) { >> "%INSTALL_SCRIPT%" echo install_policy^(^) {
>> "%INSTALL_SCRIPT%" echo echo "Installing WireGuard Policy Firewall..." >> "%INSTALL_SCRIPT%" echo echo "Installing WireGuard Policy Firewall..."
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Checking dependencies..." >> "%INSTALL_SCRIPT%" echo echo "Checking dependencies..."
>> "%INSTALL_SCRIPT%" echo apt-get update -y ^|^| true >> "%INSTALL_SCRIPT%" echo apt-get update -y ^|^| true
>> "%INSTALL_SCRIPT%" echo apt-get install -y jq inotify-tools ipset iptables ^|^| true >> "%INSTALL_SCRIPT%" echo apt-get install -y jq inotify-tools nftables ^|^| true
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Writing scripts to /usr/local/bin/..." >> "%INSTALL_SCRIPT%" echo echo "Writing scripts to /usr/local/bin/..."
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
@@ -49,6 +115,8 @@ call :AppendFile wg-policy-health.timer /etc/systemd/system/wg-policy-health.tim
>> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy.service >> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy.service
>> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy-health.timer >> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy-health.timer
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo install_hooks
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Installation complete!" >> "%INSTALL_SCRIPT%" echo echo "Installation complete!"
>> "%INSTALL_SCRIPT%" echo echo "You can check status with: wg-policy-ctl status" >> "%INSTALL_SCRIPT%" echo echo "You can check status with: wg-policy-ctl status"
>> "%INSTALL_SCRIPT%" echo } >> "%INSTALL_SCRIPT%" echo }
@@ -64,6 +132,8 @@ call :AppendFile wg-policy-health.timer /etc/systemd/system/wg-policy-health.tim
>> "%INSTALL_SCRIPT%" echo /usr/local/bin/wg-policy-cleanup.sh ^|^| true >> "%INSTALL_SCRIPT%" echo /usr/local/bin/wg-policy-cleanup.sh ^|^| true
>> "%INSTALL_SCRIPT%" echo fi >> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo remove_hooks
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Removing systemd units..." >> "%INSTALL_SCRIPT%" echo echo "Removing systemd units..."
>> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy.service >> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy.service
>> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy-health.service >> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy-health.service
+71 -1
View File
@@ -20,12 +20,78 @@ if [[ $EUID -ne 0 ]]; then
exit 1 exit 1
fi fi
if ! command -v wg &>/dev/null; then
echo "[ERROR] WireGuard is not installed."
echo " Debian/Ubuntu: apt install wireguard"
echo " RHEL/CentOS: dnf install wireguard-tools"
echo " Arch: pacman -S wireguard-tools"
exit 1
fi
if [[ ! -f /etc/wireguard/wg0.conf ]]; then
echo "[WARN] WireGuard config not found: /etc/wireguard/wg0.conf"
echo " PostUp/PostDown hooks will not be added automatically."
echo " Create your wg0.conf first, then re-install."
fi
WG_CONF="/etc/wireguard/wg0.conf"
install_hooks() {
if [[ ! -f "$WG_CONF" ]]; then
return 0
fi
if grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then
echo "[OK] PostUp/PostDown hooks already present in $WG_CONF"
return 0
fi
echo "Adding PostUp/PostDown hooks to $WG_CONF..."
cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)"
local peer_line
peer_line=$(grep -n '^\[Peer\]' "$WG_CONF" | head -1 | cut -d: -f1)
if [[ -n "$peer_line" ]]; then
sed -i "${peer_line}i\\
# WireGuard Policy Firewall hooks\\
PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh\\
PostDown = /usr/local/bin/wg-policy-cleanup.sh" "$WG_CONF"
else
{
echo ""
echo "# WireGuard Policy Firewall hooks"
echo "PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh"
echo "PostDown = /usr/local/bin/wg-policy-cleanup.sh"
} >> "$WG_CONF"
fi
echo "[OK] Hooks added to $WG_CONF"
}
remove_hooks() {
if [[ ! -f "$WG_CONF" ]]; then
return 0
fi
if ! grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then
return 0
fi
echo "Removing PostUp/PostDown hooks from $WG_CONF..."
cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)"
sed -i '/# WireGuard Policy Firewall hooks/d' "$WG_CONF"
sed -i '/wg-sync-policy\.sh/d' "$WG_CONF"
sed -i '/wg-policy-cleanup\.sh/d' "$WG_CONF"
echo "[OK] Hooks removed from $WG_CONF"
}
install_policy() { install_policy() {
echo "Installing WireGuard Policy Firewall..." echo "Installing WireGuard Policy Firewall..."
echo "Checking dependencies..." echo "Checking dependencies..."
apt-get update -y || true apt-get update -y || true
apt-get install -y jq inotify-tools ipset iptables || true apt-get install -y jq inotify-tools nftables || true
echo "Writing scripts to /usr/local/bin/..." echo "Writing scripts to /usr/local/bin/..."
@@ -75,6 +141,8 @@ cat << 'MAIN_EOF_END' >> "$INSTALL_SCRIPT"
systemctl enable --now wg-policy.service systemctl enable --now wg-policy.service
systemctl enable --now wg-policy-health.timer systemctl enable --now wg-policy-health.timer
install_hooks
echo "Installation complete!" echo "Installation complete!"
echo "You can check status with: wg-policy-ctl status" echo "You can check status with: wg-policy-ctl status"
} }
@@ -90,6 +158,8 @@ uninstall_policy() {
/usr/local/bin/wg-policy-cleanup.sh || true /usr/local/bin/wg-policy-cleanup.sh || true
fi fi
remove_hooks
echo "Removing systemd units..." echo "Removing systemd units..."
rm -f /etc/systemd/system/wg-policy.service rm -f /etc/systemd/system/wg-policy.service
rm -f /etc/systemd/system/wg-policy-health.service rm -f /etc/systemd/system/wg-policy-health.service
-30
View File
@@ -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
-39
View File
@@ -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
)
-124
View File
@@ -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=
+1199 -105
View File
File diff suppressed because it is too large Load Diff
-45
View File
@@ -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."
-49
View File
@@ -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."
+2 -56
View File
@@ -9,63 +9,9 @@ source "${SCRIPT_DIR}/wg-policy-lib.sh"
main() { main() {
log_info "Starting cleanup..." log_info "Starting cleanup..."
local DEF_IF nft delete table inet wg_policy 2>/dev/null || true
DEF_IF="$(detect_default_if)" log_info "Removed nftables table inet wg_policy"
# === Base Routing Cleanup ===
while iptables -D FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; do :; done
while iptables -t nat -D POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; do :; done
log_info "Removed base routing and NAT rules"
# === IPv4 chain cleanup ===
local removed=0
while true; do
local line=""
line=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true)
if [[ -n "$line" ]]; then
iptables -D FORWARD "$line" 2>/dev/null || break
(( removed++ ))
else
break
fi
done
if (( removed > 0 )); then
log_info "Removed $removed FORWARD references"
fi
iptables -F "$CHAIN" 2>/dev/null || true
iptables -X "$CHAIN" 2>/dev/null || true
# Cleanup backup chain too
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true
# === IPv6 chain cleanup ===
if command -v ip6tables &>/dev/null; then
while true; do
local line6=""
line6=$(ip6tables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true)
if [[ -n "$line6" ]]; then
ip6tables -D FORWARD "$line6" 2>/dev/null || break
else
break
fi
done
ip6tables -F "$CHAIN" 2>/dev/null || true
ip6tables -X "$CHAIN" 2>/dev/null || true
ip6tables -F "$CHAIN_BACKUP" 2>/dev/null || true
ip6tables -X "$CHAIN_BACKUP" 2>/dev/null || true
fi
# === ipset cleanup ===
destroy_ipset "$IPSET_V4" 2>/dev/null || true
destroy_ipset "$IPSET_V6" 2>/dev/null || true
destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true
destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true
# === Lock cleanup ===
rm -f "$LOCK_FILE" 2>/dev/null || true rm -f "$LOCK_FILE" 2>/dev/null || true
log_info "Cleanup complete" log_info "Cleanup complete"
+15 -28
View File
@@ -13,11 +13,11 @@ Usage: $(basename "$0") <command>
Commands: Commands:
status Show full health check report status Show full health check report
policy Display current policy.json formatted policy Display current policy.json formatted
rules Show current iptables rules in WG_POLICY chain rules Show current nftables rules
ipset Show ipset contents ipset Show nftables set contents
log Tail WG_DROP logs (last 50 lines) log Tail WG_DROP logs (last 50 lines)
reload Force re-sync and re-apply policy reload Force re-sync and re-apply policy
backup Manual backup of policy + iptables backup Manual backup of policy + nftables
stats Show connection and rule statistics stats Show connection and rule statistics
validate Validate policy.json without applying validate Validate policy.json without applying
help Show this help help Show this help
@@ -43,27 +43,18 @@ cmd_policy() {
} }
cmd_rules() { cmd_rules() {
echo "=== IPv4 Chain: $CHAIN ===" echo "=== Table: $NFT_TABLE_FULL ==="
if iptables -L "$CHAIN" -n -v --line-numbers 2>/dev/null; then if nft list table "$NFT_TABLE_FULL" 2>/dev/null; then
echo "" echo ""
else else
echo "(chain not found)" echo "(table not found)"
fi
echo "=== FORWARD references ==="
iptables -L FORWARD -n -v --line-numbers 2>/dev/null | grep -i "$CHAIN" || echo "(none)"
if command -v ip6tables &>/dev/null; then
echo ""
echo "=== IPv6 Chain: $CHAIN ==="
ip6tables -L "$CHAIN" -n -v --line-numbers 2>/dev/null || echo "(chain not found)"
fi fi
} }
cmd_ipset() { cmd_ipset() {
for set_name in "$IPSET_V4" "$IPSET_V6"; do for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do
echo "=== ipset: $set_name ===" echo "=== nft set: $set_name ==="
if ipset list "$set_name" 2>/dev/null; then if nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null; then
echo "" echo ""
else else
echo "(not found)" echo "(not found)"
@@ -94,7 +85,7 @@ cmd_reload() {
cmd_backup() { cmd_backup() {
backup_policy backup_policy
backup_iptables backup_nftables
log_info "Manual backup complete. Files in: $BACKUP_DIR" log_info "Manual backup complete. Files in: $BACKUP_DIR"
} }
@@ -111,18 +102,14 @@ cmd_stats() {
jq -r '.clients // {} | to_entries[] | select(.value.access | length == 0) | .key' "$POLICY_FILE" 2>/dev/null || echo "N/A" jq -r '.clients // {} | to_entries[] | select(.value.access | length == 0) | .key' "$POLICY_FILE" 2>/dev/null || echo "N/A"
echo "" echo ""
echo "=== Active iptables rules ===" echo "=== Active nftables rules ==="
iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l || echo "N/A" nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '#' || echo "N/A"
echo "" echo ""
echo "=== Drop count (since boot) ===" echo "=== nft set entries ==="
iptables -L "$CHAIN" -n -v 2>/dev/null | grep "DROP" | awk '{sum += $1} END {print sum+0, "packets dropped"}' for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do
echo ""
echo "=== ipset entries ==="
for set_name in "$IPSET_V4" "$IPSET_V6"; do
local count local count
count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c -E '^\s+[0-9a-f]' || echo 0)
echo " $set_name: $count entries" echo " $set_name: $count entries"
done done
} }
+165 -220
View File
@@ -1,55 +1,152 @@
#!/bin/bash #!/bin/bash
# wg-policy-engine.sh — Applies iptables/ipset rules from policy.json # wg-policy-engine.sh — Applies nftables rules from policy.json
# Fixed: unquoted variables, LOG placement, LAN block targeting,
# atomic chain swap, ipset, IPv6 optional, rollback on failure
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/wg-policy-lib.sh" source "${SCRIPT_DIR}/wg-policy-lib.sh"
NFT_FILE="/tmp/wg-policy.nft"
NFT_BACKUP="/tmp/wg-policy-backup.nft"
# ============================================================ # ============================================================
# ROLLBACK # ROLLBACK
# ============================================================ # ============================================================
rollback() { rollback() {
log_error "ROLLBACK triggered! Restoring previous rules..." log_error "ROLLBACK triggered! Restoring previous ruleset..."
trap - ERR
# Remove new chain references nft delete table "$NFT_TABLE_FULL" 2>/dev/null || true
while true; do
local rline="" if [[ -f "$NFT_BACKUP" ]]; then
rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) if nft -f "$NFT_BACKUP" 2>/dev/null; then
if [[ -n "$rline" ]]; then log_info "Rollback: restored from backup"
iptables -D FORWARD "$rline" 2>/dev/null || break
else else
break log_error "Rollback: failed to restore from backup"
fi fi
done fi
}
# Flush and remove new chain # ============================================================
iptables -F "$CHAIN" 2>/dev/null || true # RULESET GENERATION
iptables -X "$CHAIN" 2>/dev/null || true # ============================================================
# Restore backup chain if it exists generate_ruleset() {
if iptables -L "$CHAIN_BACKUP" -n &>/dev/null; then local WG_SUBNET="$1"
# Rename backup chain to active local WG_SUBNET_V6="$2"
iptables -N "$CHAIN" 2>/dev/null || iptables -F "$CHAIN" local LAN_SUBNETS="$3"
# Copy rules from backup local DEF_IF="$4"
iptables-save -c | grep "^-A $CHAIN_BACKUP" | \
sed "s/-A $CHAIN_BACKUP/-A $CHAIN/" | \
iptables-restore -c 2>/dev/null || true
iptables -A FORWARD -i "$WG_IF" -j "$CHAIN" cat > "$NFT_FILE" << 'HEADER'
log_info "Rollback: restored from backup chain" #!/usr/sbin/nft -f
flush ruleset
table inet wg_policy {
set wg_allowed_v4 {
type ipv4_addr . ipv4_addr
flags interval
}
set wg_allowed_v6 {
type ipv6_addr . ipv6_addr
flags interval
}
chain forward {
type filter hook forward priority filter; policy accept;
ct state established,related accept
iifname "wg0" ip saddr . ip daddr @wg_allowed_v4 accept
iifname "wg0" ip6 saddr . ip6 daddr @wg_allowed_v6 accept
HEADER
# Client isolation (IPv4)
if [[ -n "$WG_SUBNET" ]]; then
cat >> "$NFT_FILE" << EOF
/* isolation: WG client to WG client */
iifname "wg0" ip saddr $WG_SUBNET ip daddr $WG_SUBNET ct state new drop
EOF
fi fi
# Cleanup backup chain # Client isolation (IPv6)
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true if [[ -n "$WG_SUBNET_V6" ]]; then
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true cat >> "$NFT_FILE" << EOF
# Cleanup backup ipsets /* IPv6 isolation */
destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr $WG_SUBNET_V6 ct state new drop
destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr fe80::/10 drop
iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr fc00::/7 drop
EOF
fi
# LAN block (IPv4)
if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then
local lan_list=""
while IFS= read -r subnet; do
[[ -z "$subnet" ]] && continue
[[ "$subnet" == "$WG_SUBNET" ]] && continue
if [[ -n "$lan_list" ]]; then
lan_list+=", $subnet"
else
lan_list="$subnet"
fi
done <<< "$LAN_SUBNETS"
if [[ -n "$lan_list" ]]; then
cat >> "$NFT_FILE" << EOF
/* LAN block */
iifname "wg0" ip saddr $WG_SUBNET ip daddr { $lan_list } drop
EOF
fi
fi
# Internet access per client (inline in chain)
while IFS= read -r client_ip; do
[[ -z "$client_ip" ]] && continue
cat >> "$NFT_FILE" << EOF
/* internet access: $client_ip */
iifname "wg0" ip saddr $client_ip accept
EOF
done < <(jq -r '
.clients // {} | to_entries[] |
select(.value.internet == "true") |
.key
' "$POLICY_FILE" 2>/dev/null)
while IFS= read -r client_ip; do
[[ -z "$client_ip" ]] && continue
cat >> "$NFT_FILE" << EOF
/* internet access v6: $client_ip */
iifname "wg0" ip6 saddr $client_ip accept
EOF
done < <(jq -r '
.clients // {} | to_entries[] |
select(.value.internet == "true") |
select(.key | test(":")) |
.key
' "$POLICY_FILE" 2>/dev/null)
# Log + drop (final rule)
cat >> "$NFT_FILE" << EOF
/* log + drop */
iifname "wg0" limit rate ${LOG_RATE} log prefix "${LOG_PREFIX}: " drop
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "${DEF_IF}" masquerade
}
}
EOF
} }
# ============================================================ # ============================================================
@@ -75,8 +172,8 @@ main() {
exit 1 exit 1
fi fi
# Backup iptables state # Backup current nftables state
backup_iptables backup_nftables
# Set trap for rollback on failure # Set trap for rollback on failure
trap 'rollback' ERR trap 'rollback' ERR
@@ -105,221 +202,69 @@ main() {
fi fi
# === BASE ROUTING & NAT === # === BASE ROUTING & NAT ===
# Enable IP Forwarding
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
if command -v ip6tables &>/dev/null; then
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
# === BACKUP EXISTING TABLE ===
if nft list table "$NFT_TABLE_FULL" &>/dev/null; then
nft list table "$NFT_TABLE_FULL" > "$NFT_BACKUP" 2>/dev/null || true
log_info "Backed up existing table to $NFT_BACKUP"
fi fi
# Setup MASQUERADE on default interface # === DELETE OLD TABLE ===
if ! iptables -t nat -C POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; then nft delete table "$NFT_TABLE_FULL" 2>/dev/null || true
iptables -t nat -A POSTROUTING -o "$DEF_IF" -j MASQUERADE
log_info "Enabled IPv4 MASQUERADE on $DEF_IF"
fi
# === CLEANUP OLD CHAIN (loop until all references removed) === # === GENERATE AND LOAD NEW RULESET ===
log_info "Cleaning up old chain references..." generate_ruleset "$WG_SUBNET" "$WG_SUBNET_V6" "$LAN_SUBNETS" "$DEF_IF"
while true; do log_info "Generated ruleset: $NFT_FILE"
local rline=""
rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true)
if [[ -n "$rline" ]]; then
iptables -D FORWARD "$rline" 2>/dev/null || break
else
break
fi
done
# Backup existing chain before flushing nft -f "$NFT_FILE"
if iptables -L "$CHAIN" -n &>/dev/null; then log_info "Loaded nftables ruleset from $NFT_FILE"
iptables -N "$CHAIN_BACKUP" 2>/dev/null || iptables -F "$CHAIN_BACKUP"
iptables-save -c 2>/dev/null | grep "^-A $CHAIN" | \
sed "s/-A $CHAIN/-A $CHAIN_BACKUP/" | \
iptables-restore -c 2>/dev/null || true
log_info "Backed up existing chain to $CHAIN_BACKUP"
fi
iptables -F "$CHAIN" 2>/dev/null || true # === POPULATE SETS ===
iptables -X "$CHAIN" 2>/dev/null || true log_info "Populating whitelist sets..."
# === CREATE FRESH CHAIN === local v4_count=0
iptables -N "$CHAIN" local v6_count=0
if ! iptables -C FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; then while IFS= read -r line; do
iptables -I FORWARD 1 -i "$WG_IF" -j "$CHAIN" [[ -z "$line" ]] && continue
fi local client_ip target
client_ip=$(echo "$line" | awk '{print $1}')
if ! iptables -C FORWARD -o "$WG_IF" -j "$CHAIN" 2>/dev/null; then target=$(echo "$line" | awk '{print $2}')
iptables -I FORWARD 2 -o "$WG_IF" -j "$CHAIN"
fi
log_info "Chain $CHAIN created and linked to FORWARD (In/Out)"
# === POPULATE IPSET (hash:net,net for source->target mapping) ===
local use_ipset=false
if has_ipset; then
use_ipset=true
log_info "Populating ipsets..."
ensure_ipset "$IPSET_V4" "inet"
flush_ipset "$IPSET_V4"
# Check if we need IPv6 ipset
local use_ipv6=false
if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then
use_ipv6=true
ensure_ipset "$IPSET_V6" "inet6"
flush_ipset "$IPSET_V6"
fi
# Read all access entries and populate ipset (client_ip,target)
jq -r '
.clients // {} | to_entries[] |
select(.value.access != null and (.value.access | length > 0)) |
.key as $ip |
.value.access[] |
"\($ip) \(.)"
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do
[[ -z "$client_ip" || -z "$target" ]] && continue [[ -z "$client_ip" || -z "$target" ]] && continue
if [[ "$target" == *":"* ]]; then if [[ "$target" == *":"* ]]; then
if [[ "$use_ipv6" == true ]]; then nft add element "$NFT_TABLE_FULL" "$NFT_SET_V6" { "$client_ip" . "$target" } 2>/dev/null || \
ipset add "$IPSET_V6" "${client_ip},${target}" 2>/dev/null || \ log_warn "Failed to add ${client_ip} . ${target} to set $NFT_SET_V6"
log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V6" (( v6_count++ )) || true
fi
else else
ipset add "$IPSET_V4" "${client_ip},${target}" 2>/dev/null || \ nft add element "$NFT_TABLE_FULL" "$NFT_SET_V4" { "$client_ip" . "$target" } 2>/dev/null || \
log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V4" log_warn "Failed to add ${client_ip} . ${target} to set $NFT_SET_V4"
(( v4_count++ )) || true
fi fi
done done < <(jq -r '
local v4_count v6_count
v4_count=$(ipset list "$IPSET_V4" 2>/dev/null | grep -c '^[0-9]' || echo 0)
v6_count=$(ipset list "$IPSET_V6" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0)
log_info "ipset $IPSET_V4: $v4_count entries, $IPSET_V6: $v6_count entries"
else
log_warn "ipset not installed, falling back to per-rule iptables whitelist"
local use_ipv6=false
if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then
use_ipv6=true
fi
fi
# === RULE 1: ESTABLISHED,RELATED — allow return traffic ===
iptables -A "$CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# === RULE 2: WHITELIST (per-client source) ===
if [[ "$use_ipset" == true ]]; then
iptables -A "$CHAIN" -m set --match-set "$IPSET_V4" src,dst -j ACCEPT
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -m set --match-set "$IPSET_V6" src,dst -j ACCEPT 2>/dev/null || true
fi
else
jq -r '
.clients // {} | to_entries[] | .clients // {} | to_entries[] |
select(.value.access != null and (.value.access | length > 0)) | select(.value.access != null and (.value.access | length > 0)) |
.key as $ip | .key as $ip |
.value.access[] | .value.access[] |
"\($ip) \(.)" "\($ip) \(.)"
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do ' "$POLICY_FILE" 2>/dev/null)
[[ -z "$client_ip" || -z "$target" ]] && continue
if [[ "$client_ip" == *":"* ]]; then log_info "Set $NFT_SET_V4: $v4_count entries, $NFT_SET_V6: $v6_count entries"
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT 2>/dev/null || true
fi
else
iptables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT
fi
done
fi
# === RULE 3: ISOLATION — drop NEW connections between WG clients === # === CLEANUP BACKUP (success path) ===
if [[ -n "$WG_SUBNET" ]]; then rm -f "$NFT_BACKUP" 2>/dev/null || true
iptables -A "$CHAIN" \
-s "$WG_SUBNET" \
-d "$WG_SUBNET" \
-m conntrack --ctstate NEW \
-j DROP
log_info "Client isolation enabled for $WG_SUBNET"
fi
if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then
ip6tables -A "$CHAIN" \
-s "$WG_SUBNET_V6" \
-d "$WG_SUBNET_V6" \
-m conntrack --ctstate NEW \
-j DROP 2>/dev/null || true
log_info "Client isolation enabled for IPv6 $WG_SUBNET_V6"
fi
# === RULE 4: BLOCK LAN — drop from WG subnet to private LAN ===
if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then
echo "$LAN_SUBNETS" | while read -r subnet; do
[[ -z "$subnet" ]] && continue
# Skip if LAN subnet exactly matches WG subnet (handled by Rule 3)
[[ "$subnet" == "$WG_SUBNET" ]] && continue
iptables -A "$CHAIN" -s "$WG_SUBNET" -d "$subnet" -j DROP
log_info "Block: $WG_SUBNET$subnet"
done
fi
# IPv6 LAN block (link-local and ULA)
if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then
# Block to link-local (fe80::/10)
ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fe80::/10" -j DROP 2>/dev/null || true
# Block to ULA (fc00::/7)
ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fc00::/7" -j DROP 2>/dev/null || true
log_info "IPv6 LAN block applied (link-local + ULA)"
fi
# === RULE 5: INTERNET ACCESS (#Internet = true) ===
jq -r '
.clients // {} | to_entries[] |
select(.value.internet == "true") |
"\(.key)"
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip; do
[[ -z "$client_ip" ]] && continue
if [[ "$client_ip" == *":"* ]]; then
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -s "$client_ip" -j ACCEPT 2>/dev/null || true
fi
else
iptables -A "$CHAIN" -s "$client_ip" -j ACCEPT
fi
done
# === RULE 6: LOGGING (rate-limited) — BEFORE final DROP ===
iptables -A "$CHAIN" \
-m limit --limit "$LOG_RATE" \
-j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" \
-m limit --limit "$LOG_RATE" \
-j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4 2>/dev/null || true
fi
# === RULE 7: DEFAULT DROP (internet block by default) ===
iptables -A "$CHAIN" -j DROP
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -j DROP 2>/dev/null || true
fi
# === CLEANUP BACKUP CHAIN (no rollback needed anymore) ===
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true
# Disable ERR trap (success path) # Disable ERR trap (success path)
trap - ERR trap - ERR
# === VERIFY === # === VERIFY ===
local rule_count local rule_count
rule_count=$(iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l) rule_count=$(nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '^\s*' || echo 0)
log_info "Policy applied. Chain: $CHAIN, Rules: $rule_count" log_info "Policy applied. Table: $NFT_TABLE, Rules: $rule_count"
echo "[OK] iptables policy applied. Chain: $CHAIN" echo "[OK] nftables policy applied. Table: $NFT_TABLE"
} }
main "$@" main "$@"
+29 -72
View File
@@ -8,16 +8,16 @@ set -euo pipefail
# CONFIGURATION # CONFIGURATION
# ============================================================ # ============================================================
readonly WG_IF="${WG_IF:-wg0}" readonly WG_IF="${WG_IF:-wg0}"
readonly CHAIN="WG_POLICY" readonly NFT_TABLE="wg_policy"
readonly CHAIN_BACKUP="WG_POLICY_BAK" readonly NFT_TABLE_FULL="inet wg_policy"
readonly NFT_SET_V4="wg_allowed_v4"
readonly NFT_SET_V6="wg_allowed_v6"
readonly POLICY_FILE="/etc/wireguard/policy.json" readonly POLICY_FILE="/etc/wireguard/policy.json"
readonly WG_CONF="/etc/wireguard/wg0.conf" readonly WG_CONF="/etc/wireguard/wg0.conf"
readonly LOCK_FILE="/var/lock/wg-policy.lock" readonly LOCK_FILE="/var/lock/wg-policy.lock"
readonly BACKUP_DIR="/etc/wireguard/backups" readonly BACKUP_DIR="/etc/wireguard/backups"
readonly LOG_PREFIX="WG_DROP" readonly LOG_PREFIX="WG_DROP"
readonly LOG_RATE="10/min" readonly LOG_RATE="10/minute"
readonly IPSET_V4="wg_allowed_v4"
readonly IPSET_V6="wg_allowed_v6"
readonly MAX_RETRY=3 readonly MAX_RETRY=3
readonly RETRY_DELAY=2 readonly RETRY_DELAY=2
readonly DEBOUNCE_SEC=2 readonly DEBOUNCE_SEC=2
@@ -33,17 +33,14 @@ log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; }
# VALIDATION # VALIDATION
# ============================================================ # ============================================================
# Validate IPv4 address (strict: 0-255 per octet, no leading zeros)
validate_ipv4() { validate_ipv4() {
local ip="$1" local ip="$1"
# Match basic pattern
if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then
return 1 return 1
fi fi
local IFS='.' local IFS='.'
read -ra octets <<< "$ip" read -ra octets <<< "$ip"
for octet in "${octets[@]}"; do for octet in "${octets[@]}"; do
# Reject leading zeros (except "0" itself)
if [[ "$octet" =~ ^0[0-9] ]]; then if [[ "$octet" =~ ^0[0-9] ]]; then
return 1 return 1
fi fi
@@ -54,7 +51,6 @@ validate_ipv4() {
return 0 return 0
} }
# Validate IPv4 CIDR (e.g., 192.168.1.0/24)
validate_ipv4_cidr() { validate_ipv4_cidr() {
local cidr="$1" local cidr="$1"
local ip prefix local ip prefix
@@ -63,7 +59,6 @@ validate_ipv4_cidr() {
ip="${cidr%%/*}" ip="${cidr%%/*}"
prefix="${cidr##*/}" prefix="${cidr##*/}"
else else
# Single IP treated as /32
ip="$cidr" ip="$cidr"
prefix="32" prefix="32"
fi fi
@@ -78,10 +73,8 @@ validate_ipv4_cidr() {
return 0 return 0
} }
# Validate IPv6 address (basic check)
validate_ipv6() { validate_ipv6() {
local ip="$1" local ip="$1"
# Basic IPv6 pattern — covers full, compressed, and mixed notation
if [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]] || \ if [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]] || \
[[ "$ip" =~ ^::([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$ ]] || \ [[ "$ip" =~ ^::([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$ ]] || \
[[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){1,7}:$ ]] || \ [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){1,7}:$ ]] || \
@@ -92,7 +85,6 @@ validate_ipv6() {
return 1 return 1
} }
# Validate IPv6 CIDR
validate_ipv6_cidr() { validate_ipv6_cidr() {
local cidr="$1" local cidr="$1"
local ip prefix local ip prefix
@@ -115,7 +107,6 @@ validate_ipv6_cidr() {
return 0 return 0
} }
# Generic CIDR validator — dispatches to v4 or v6
validate_cidr() { validate_cidr() {
local cidr="$1" local cidr="$1"
if [[ "$cidr" == *":"* ]]; then if [[ "$cidr" == *":"* ]]; then
@@ -126,36 +117,11 @@ validate_cidr() {
} }
# ============================================================ # ============================================================
# IPSET MANAGEMENT # NFTABLES HELPERS
# ============================================================ # ============================================================
has_ipset() { has_nft() {
command -v ipset &>/dev/null command -v nft &>/dev/null
}
ensure_ipset() {
local name="$1" family="$2"
has_ipset || return 0
if ! ipset list "$name" &>/dev/null; then
ipset create "$name" hash:net,net family "$family" hashsize 1024 maxelem 65536 timeout 0
log_info "Created ipset: $name (family=$family)"
fi
}
flush_ipset() {
local name="$1"
has_ipset || return 0
if ipset list "$name" &>/dev/null; then
ipset flush "$name"
fi
}
destroy_ipset() {
local name="$1"
has_ipset || return 0
if ipset list "$name" &>/dev/null; then
ipset destroy "$name"
fi
} }
# ============================================================ # ============================================================
@@ -176,7 +142,7 @@ retry() {
log_warn "Attempt $attempt/$max_attempts failed (exit=$exit_code), retrying in ${delay}s..." log_warn "Attempt $attempt/$max_attempts failed (exit=$exit_code), retrying in ${delay}s..."
sleep "$delay" sleep "$delay"
(( attempt++ )) (( attempt++ ))
(( delay *= 2 )) # exponential backoff (( delay *= 2 ))
done done
log_error "All $max_attempts attempts failed for: $*" log_error "All $max_attempts attempts failed for: $*"
@@ -198,7 +164,6 @@ acquire_lock() {
} }
release_lock() { release_lock() {
# Lock released automatically when fd closes, but we clean up file
rm -f "$LOCK_FILE" 2>/dev/null || true rm -f "$LOCK_FILE" 2>/dev/null || true
} }
@@ -216,7 +181,6 @@ backup_policy() {
log_info "Backup created: ${BACKUP_DIR}/policy_${timestamp}.json" log_info "Backup created: ${BACKUP_DIR}/policy_${timestamp}.json"
fi fi
# Keep only last 50 backups
local count local count
count=$(find "$BACKUP_DIR" -name 'policy_*.json' -type f | wc -l) count=$(find "$BACKUP_DIR" -name 'policy_*.json' -type f | wc -l)
if (( count > 50 )); then if (( count > 50 )); then
@@ -229,31 +193,24 @@ backup_policy() {
fi fi
} }
backup_iptables() { backup_nftables() {
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
local timestamp local timestamp
timestamp="$(date '+%Y%m%d_%H%M%S')" timestamp="$(date '+%Y%m%d_%H%M%S')"
if iptables-save > "${BACKUP_DIR}/iptables_${timestamp}.rules" 2>/dev/null; then if nft list ruleset > "${BACKUP_DIR}/nftables_${timestamp}.rules" 2>/dev/null; then
log_info "iptables backup: ${BACKUP_DIR}/iptables_${timestamp}.rules" log_info "nftables backup: ${BACKUP_DIR}/nftables_${timestamp}.rules"
fi fi
if command -v ip6tables-save &>/dev/null; then
ip6tables-save > "${BACKUP_DIR}/ip6tables_${timestamp}.rules" 2>/dev/null || true
fi
# Keep only last 20 iptables backups
for prefix in iptables ip6tables; do
local count local count
count=$(find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f | wc -l) count=$(find "$BACKUP_DIR" -name 'nftables_*.rules' -type f | wc -l)
if (( count > 20 )); then if (( count > 20 )); then
find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f -printf '%T@ %p\n' \ find "$BACKUP_DIR" -name 'nftables_*.rules' -type f -printf '%T@ %p\n' \
| sort -n \ | sort -n \
| head -n $(( count - 20 )) \ | head -n $(( count - 20 )) \
| awk '{print $2}' \ | awk '{print $2}' \
| xargs rm -f | xargs rm -f
fi fi
done
} }
# ============================================================ # ============================================================
@@ -314,32 +271,32 @@ health_check() {
status=1 status=1
fi fi
# 3. Check chain exists # 3. Check nftables table exists
if iptables -L "$CHAIN" -n &>/dev/null; then if nft list table "$NFT_TABLE_FULL" &>/dev/null; then
local rule_count local rule_count
rule_count=$(iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l) rule_count=$(nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '^\s*' || echo 0)
report+="[OK] Chain $CHAIN active ($rule_count rules)\n" report+="[OK] Table $NFT_TABLE active ($rule_count rules)\n"
else else
report+="[WARN] Chain $CHAIN not found\n" report+="[WARN] Table $NFT_TABLE not found\n"
status=1 status=1
fi fi
# 4. Check FORWARD reference # 4. Check forward chain exists in table
if iptables -L FORWARD -n 2>/dev/null | grep -q "$CHAIN"; then if nft list chain "$NFT_TABLE_FULL" forward &>/dev/null; then
report+="[OK] FORWARD chain references $CHAIN\n" report+="[OK] Forward chain exists in $NFT_TABLE\n"
else else
report+="[WARN] FORWARD chain has no reference to $CHAIN\n" report+="[WARN] Forward chain not found in $NFT_TABLE\n"
status=1 status=1
fi fi
# 5. Check ipset # 5. Check nftables sets
for set_name in "$IPSET_V4" "$IPSET_V6"; do for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do
if ipset list "$set_name" &>/dev/null; then if nft list set "$NFT_TABLE_FULL" "$set_name" &>/dev/null; then
local entry_count local entry_count
entry_count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) entry_count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c '^\s*' || echo 0)
report+="[OK] ipset $set_name active ($entry_count entries)\n" report+="[OK] Set $set_name active ($entry_count entries)\n"
else else
report+="[INFO] ipset $set_name not created (may not be needed)\n" report+="[INFO] Set $set_name not created (may not be needed)\n"
fi fi
done done
-14
View File
@@ -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