feat: WGRplane Hybrid - Go-native with nftables + Multi-Webhook

This commit is contained in:
datadunia
2026-05-03 18:03:38 +07:00
parent 477f7830bb
commit 3eae539a56
33 changed files with 3091 additions and 4 deletions
Submodule app deleted from d4462053a5
+27
View File
@@ -0,0 +1,27 @@
# 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`
+215
View File
@@ -0,0 +1,215 @@
# WGRplane - WireGuard Remote Plane Control
**WGRplane** adalah aplikasi kontrol WireGuard Remote Plane yang dibangun dengan fitur lengkap (paritas penuh dengan **WGDashboard**) ditambah integrasi **policy.json API**.
---
## Fitur Utama
- **Peer Management**: CRUD peer, generate QR code, export config
- **Real-time Monitoring**: Status peer, grafik trafik, riwayat koneksi
- **Scheduling & Automation**: Jadwal penghapusan/restriksi peer, reset data usage
- **Security**: Autentikasi dashboard (username/password), TOTP (2FA), API key
- **Multi-Server**: Akses multi WGDashboard instance via API keys
- **Plugins System**: Ekspansi fitur via plugin (experimental)
- **i18n & Themes**: Multi-bahasa, dark/light mode
---
## Arsitektur
```
wg0.conf (dengan/tanpa #Access)
wg-engine-api (Go) -- membaca API storage (api-policy.json)
↓ ↓
+-- GET /api/policy (merged: API + #Access fallback)
+-- POST /api/policy (write ke api-policy.json, trigger sync)
policy.json (merged: API overrides #Access)
wg-policy-engine.sh (tidak diubah)
iptables / ipset rules
```
### Tech Stack
| Komponen | Teknologi |
|-----------|------------|
| **WGRplane Backend** | Python + Flask |
| **WGRplane Frontend** | Vue.js 3 |
| **wg-engine-api** | Go (Golang) |
| **Database** | SQLite (default), PostgreSQL/MySQL via SQLAlchemy |
| **Desktop App** | ElectronJS + Vue.js |
---
## Port & Autentikasi
| Service | Port | Autentikasi |
|---------|------|---------------|
| WGRplane Dashboard | **10086** | Session-based + TOTP |
| wg-engine-api (Go) | **10087** | Custom header: `wg-rplane-datadunia` |
---
## API Endpoints (wg-engine-api)
### Autentikasi
Semua request harus menyertakan header:
```
wg-rplane-datadunia: <TOKEN>
```
### Endpoints
| Endpoint | Method | Deskripsi |
|----------|--------|-------------|
| `/api/policy` | GET | Ambil policy.json (merge: API + #Access fallback) |
| `/api/policy` | POST | Update policy (simpan ke api-policy.json, trigger sync) |
| `/api/reload` | POST | Trigger wg-policy-engine.sh untuk apply rules |
### Contoh Penggunaan
```bash
# Ambil policy
curl -H "wg-rplane-datadunia: VALID_TOKEN" http://localhost:10087/api/policy
# Update policy untuk client
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"], "internet": true}' \
http://localhost:10087/api/policy
# Trigger reload
curl -X POST \
-H "wg-rplane-datadunia: VALID_TOKEN" \
http://localhost:10087/api/reload
```
---
## Migration: #Access ke API
| Aspek | Detail |
|-------|--------|
| **Strategi** | API with #Access fallback (API dicoba pertama, fallback ke #Access) |
| **Precedence** | API policy OVERRIDE #Access untuk IP client yang sama |
| **Storage API** | `/etc/wireguard/api-policy.json` (terpisah dari policy.json) |
| **Locking** | Menggunakan `/var/lock/wg-policy.lock` (SAMA dengan script shell) |
| **Atomic Write** | Tulis ke tmp file → `mv` (mencegah corrupt saat crash) |
---
## Instalasi
### Prerequisites
```bash
# Python dependencies (WGRplane)
pip install -r requirements.txt
# Go dependencies (wg-engine-api)
cd wg-engine-api
go mod download
```
### Jalankan WGRplane (Python/Flask)
```bash
python app.py
# atau dengan Gunicorn
gunicorn -w 4 -b 0.0.0.0:10086 app:app
```
### Jalankan wg-engine-api (Go)
```bash
cd wg-engine-api
go build -o wg-engine-api .
./wg-engine-api &
# atau install ke /usr/local/bin/
cp wg-engine-api /usr/local/bin/
```
---
## Testing
### Bats (Shell Scripts)
```bash
apt install bats
bats /tests/policy.bats
```
### Go Tests (wg-engine-api)
```bash
cd wg-engine-api
go test ./...
```
### Manual QA
```bash
# Test auth
curl -H "wg-rplane-datadunia: wrong" http://localhost:10087/api/policy
# Expected: 401 Unauthorized
# Test policy retrieval
curl -H "wg-rplane-datadunia: VALID_TOKEN" http://localhost:10087/api/policy
# Test 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
# Verify iptables rules
wg-policy-ctl rules
```
---
## Deployment
### Systemd Services
WGRplane menggunakan systemd untuk manajemen service:
```bash
# Copy systemd units
cp wg-policy.service wg-policy-health.timer wg-policy-health.service /etc/systemd/system/
# Enable & start
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
```
### Install Script
Gunakan `install.sh` untuk instalasi otomatis:
```bash
sudo ./install.sh install
```
---
## Dokumentasi Tambahan
- **Plan File**: `plan.md` (detail rencana implementasi 20 tasks dalam 7 fase)
- **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 mengikuti lisensi dari WGDashboard (donaldzou/WGDashboard) dan modifikasi untuk integrasi policy.json API.
+134
View File
@@ -0,0 +1,134 @@
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
@@ -0,0 +1,24 @@
# 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
@@ -0,0 +1,13 @@
<!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>frontend</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1232
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"devDependencies": {
"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-chartjs": "^5.3.3",
"vue-router": "^4.6.4",
"vue-sonner": "^2.0.9"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<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>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<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>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,8 @@
<template>
<button
class="px-6 py-2 bg-gradient-to-r from-blue-500 to-purple-600 text-white rounded-lg hover:opacity-90 transition-opacity"
v-bind="$attrs"
>
<slot></slot>
</button>
</template>
@@ -0,0 +1,5 @@
<template>
<div class="bg-white/10 backdrop-blur-md border border-white/20 rounded-xl shadow-glass p-6">
<slot></slot>
</div>
</template>
@@ -0,0 +1,6 @@
<template>
<input
class="w-full bg-white/5 border border-white/20 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-blue-500"
v-bind="$attrs"
/>
</template>
@@ -0,0 +1,22 @@
<template>
<button
class="relative w-12 h-6 rounded-full transition-colors"
:class="modelValue ? 'bg-blue-500' : 'bg-gray-600'"
@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>
+9
View File
@@ -0,0 +1,9 @@
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)
}
+60
View File
@@ -0,0 +1,60 @@
import './style.css'
import typescriptLogo from './assets/typescript.svg'
import viteLogo from './assets/vite.svg'
import heroImg from './assets/hero.png'
import { setupCounter } from './counter.ts'
document.querySelector<HTMLDivElement>('#app')!.innerHTML = `
<section id="center">
<div class="hero">
<img src="${heroImg}" class="base" width="170" height="179">
<img src="${typescriptLogo}" class="framework" alt="TypeScript logo"/>
<img src="${viteLogo}" class="vite" alt="Vite logo" />
</div>
<div>
<h1>Get started</h1>
<p>Edit <code>src/main.ts</code> and save to test <code>HMR</code></p>
</div>
<button id="counter" type="button" class="counter"></button>
</section>
<div class="ticks"></div>
<section id="next-steps">
<div id="docs">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#documentation-icon"></use></svg>
<h2>Documentation</h2>
<p>Your questions, answered</p>
<ul>
<li>
<a href="https://vite.dev/" target="_blank">
<img class="logo" src="${viteLogo}" alt="" />
Explore Vite
</a>
</li>
<li>
<a href="https://www.typescriptlang.org" target="_blank">
<img class="button-icon" src="${typescriptLogo}" alt="">
Learn more
</a>
</li>
</ul>
</div>
<div id="social">
<svg class="icon" role="presentation" aria-hidden="true"><use href="/icons.svg#social-icon"></use></svg>
<h2>Connect with us</h2>
<p>Join the Vite community</p>
<ul>
<li><a href="https://github.com/vitejs/vite" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#github-icon"></use></svg>GitHub</a></li>
<li><a href="https://chat.vite.dev/" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#discord-icon"></use></svg>Discord</a></li>
<li><a href="https://x.com/vite_js" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#x-icon"></use></svg>X.com</a></li>
<li><a href="https://bsky.app/profile/vite.dev" target="_blank"><svg class="button-icon" role="presentation" aria-hidden="true"><use href="/icons.svg#bluesky-icon"></use></svg>Bluesky</a></li>
</ul>
</div>
</section>
<div class="ticks"></div>
<section id="spacer"></section>
`
setupCounter(document.querySelector<HTMLButtonElement>('#counter')!)
+296
View File
@@ -0,0 +1,296 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+19
View File
@@ -0,0 +1,19 @@
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
backdropBlur: {
xs: '2px',
},
boxShadow: {
glass: '0 8px 32px 0 rgba(31, 38, 135, 0.37)',
},
},
},
plugins: [
require('@tailwindcss/forms'),
],
}
+23
View File
@@ -0,0 +1,23 @@
{
"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"]
}
+15
View File
@@ -0,0 +1,15 @@
module git.datadunia.com/hainzero/WGRplane
go 1.20
require (
github.com/gorilla/mux v1.8.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/patrickmn/go-cache v2.1.0+incompatible // indirect
golang.org/x/text v0.20.0 // indirect
gorm.io/driver/sqlite v1.6.0 // indirect
gorm.io/gorm v1.31.1 // indirect
)
+18
View File
@@ -0,0 +1,18 @@
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible h1:jdpOPRN1zP63Td1hDQbZW73xKmzDvZHzVdNYxhnTMDA=
github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible/go.mod h1:1c7szIrayyPPB/987hsnvNzLushdWf4o/79s3P08L8A=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+96
View File
@@ -0,0 +1,96 @@
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
@@ -0,0 +1,106 @@
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
@@ -0,0 +1,40 @@
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
@@ -0,0 +1,346 @@
# 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
- [ ] **Task A1**: Initialize WGRplane submodule at `/app`
- File: `/app` (new submodule directory)
- Command: `git submodule add https://git.datadunia.com/hainzero/WGRplane.git app`
- Followed by: `git submodule update --init --recursive`
- QA: `git submodule status` shows `app` with commit hash, no errors
- QA: `/app` directory exists with WGRplane files (Python/Flask backend, Vue.js frontend)
- [ ] **Task A2**: Verify WGRplane structure and dependencies
- File: `/app` (submodule contents)
- Inspect: `ls /app` — should contain Python backend, Vue.js frontend, requirements.txt
- Verify WGDashboard-equivalent structure: `app.py` or similar Flask entry point
- QA: WGRplane files present, Python/Flask + Vue.js stack confirmed
- QA: `cat /app/requirements.txt` shows Flask, SQLite, other dependencies
- [ ] **Task A3**: Create Go module for `wg-engine-api` in `/app`
- 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)
- [ ] **Task B1**: Review WGDashboard features for parity checklist
- Reference: Librarian findings (bg_f53966bd) — full feature list
- Features to implement: peer CRUD, QR codes, real-time monitoring, scheduling, TOTP auth, multi-server, plugins, i18n, themes
- File: `/app/README.md` (document feature parity status)
- QA: Checklist created with ALL WGDashboard features mapped to WGRplane implementation status
- [ ] **Task B2**: Configure WGRplane to use port 10086 (WGDashboard default)
- File: `/app/app.py` or `/app/config.json` (WGRplane config)
- Set: `app_port = 10086` (consistent with WGDashboard)
- Ensure: Does not conflict with `wg-engine-api` on port 10087
- QA: `curl http://localhost:10086` returns WGRplane dashboard page
- QA: Port 10086 in use by WGRplane, 10087 available for wg-engine-api
- [ ] **Task B3**: Integrate WGRplane with existing WireGuard config path
- File: `/app/app.py` (WGRplane backend)
- Set WireGuard config path: `/etc/wireguard/wg0.conf` (consistent with existing scripts)
- QA: WGRplane can read `/etc/wireguard/wg0.conf` and list peers
- QA: WGRplane "Add Peer" creates valid WireGuard config entries
- [ ] **Task B4**: Add policy.json API awareness to WGRplane frontend
- 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
- [ ] **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)
- [ ] **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)
- [ ] **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
- [ ] **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
- [ ] **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
- [ ] **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
- [ ] **Task D3**: Implement `POST /api/reload` (trigger policy engine)
- File: `/app/wg-engine-api/main.go` (reload handler)
- Action: Exec `/usr/local/bin/wg-policy-engine.sh`
- Optional: Also exec `/usr/local/bin/wg-sync-policy.sh` first (if #Access fallback needed)
- QA: `curl -X POST -H "wg-rplane-datadunia: VALID" http://localhost:10087/api/reload` returns 200
- QA: After reload, `wg-policy-ctl status` shows engine applied successfully
- [ ] **Task D4**: Implement authentication middleware
- 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
- [ ] **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
- [ ] **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
- [ ] **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"]`
- [ ] **Task E3**: Handle `internet` flag merge
- File: `/app/wg-engine-api/main.go` (merge function extension)
- Logic: Same as access merge — API `internet` flag overrides `#Internet` comment
- QA: Client with API `internet: true` + no `#Internet` in wg0.conf → GET returns `true`
- QA: Client with API `internet: false` + `#Internet true` in wg0.conf → GET returns `false`
- [ ] **Task E4**: Update `wg-policy.service` to use Go API sync (optional, recommended)
- 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
- [ ] **Task F1**: Add `bats` test framework for shell script validation
- File: `/tests/` (new directory) or use existing pattern
- Test cases: Policy.json validation, JSON structure, lock file behavior
- Install: `apt install bats` (add to `install.sh` if needed)
- QA: `bats /tests/policy.bats` passes all test cases
- QA: Test coverage for `wg-policy-ctl validate` command
- [ ] **Task F2**: Add Go tests for `wg-engine-api`
- File: `/app/wg-engine-api/main_test.go` (new)
- Test cases: Auth middleware, GET/POST handlers, merge logic, lock mechanism
- Run: `cd /app/wg-engine-api && go test ./...`
- QA: `go test` passes with >80% coverage
- QA: Mock `wg0.conf` and `api-policy.json` for isolated tests
- [ ] **Task F3**: Integration test: Full flow validation
- 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
- [ ] **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
- [ ] **Task G1**: Create comprehensive README.md for WGRplane submodule
- File: `/app/README.md` (new or update existing)
- Sections: Overview, Architecture, API Endpoints, Authentication, Integration with WGDashboard, Testing, Deployment
- Document: Go API endpoint (`http://localhost:10087/api/policy`), auth header `wg-rplane-datadunia`
- QA: README.md exists with all sections
- QA: `cat /app/README.md` shows complete documentation
- [ ] **Task G2**: Document integration between WGRplane (Python) and wg-engine-api (Go)
- File: `/app/README.md` (integration section)
- Explain: WGRplane on port 10086, Go API on port 10087
- Note: Frontend can proxy API requests or link directly
- QA: README has "Integration" section with port numbers and proxy examples
- QA: Developer understands how Python Flask talks to Go API
- [ ] **Task G3**: Push WGRplane submodule to remote
- 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
- [ ] **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.
+234
View File
@@ -0,0 +1,234 @@
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
}
+87
View File
@@ -0,0 +1,87 @@
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()
}
BIN
View File
Binary file not shown.