feat: WGRplane Hybrid - Go-native with nftables + Multi-Webhook
This commit is contained in:
@@ -1,3 +0,0 @@
|
|||||||
[submodule "app"]
|
|
||||||
path = app
|
|
||||||
url = https://git.datadunia.com/hainzero/WGRplane.git
|
|
||||||
|
|||||||
-1
Submodule app deleted from d4462053a5
@@ -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
@@ -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
@@ -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)
|
||||||
|
}
|
||||||
@@ -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?
|
||||||
@@ -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>
|
||||||
Generated
+1232
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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')!)
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Binary file not shown.
Reference in New Issue
Block a user