diff --git a/.gitmodules b/.gitmodules index a582140..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +0,0 @@ -[submodule "app"] - path = app - url = https://git.datadunia.com/hainzero/WGRplane.git diff --git a/app b/app deleted file mode 160000 index d446205..0000000 --- a/app +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d4462053a581f1f05183b68673e7b12ff33059cf diff --git a/app/AGENTS.md b/app/AGENTS.md new file mode 100644 index 0000000..6576f54 --- /dev/null +++ b/app/AGENTS.md @@ -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` diff --git a/app/README.md b/app/README.md new file mode 100644 index 0000000..4a3f239 --- /dev/null +++ b/app/README.md @@ -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: +``` + +### 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. diff --git a/app/email.go b/app/email.go new file mode 100644 index 0000000..2f97879 --- /dev/null +++ b/app/email.go @@ -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) +} diff --git a/app/frontend/.gitignore b/app/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/app/frontend/.gitignore @@ -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? diff --git a/app/frontend/index.html b/app/frontend/index.html new file mode 100644 index 0000000..096d706 --- /dev/null +++ b/app/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/app/frontend/package-lock.json b/app/frontend/package-lock.json new file mode 100644 index 0000000..1abe646 --- /dev/null +++ b/app/frontend/package-lock.json @@ -0,0 +1,1232 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "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" + }, + "devDependencies": { + "typescript": "~6.0.2", + "vite": "^8.0.10" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@headlessui/vue": { + "version": "1.7.23", + "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz", + "integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==", + "license": "MIT", + "dependencies": { + "@tanstack/vue-virtual": "^3.0.0-beta.60" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "license": "MIT", + "peer": true + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz", + "integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/vue-virtual": { + "version": "3.13.24", + "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.24.tgz", + "integrity": "sha512-A0k2qF0zFSUStXSZkGXABouXr2Tw2Ztl/cVIYG9qy84uR8W7UNjAcX3DvzBS3YnDcwvLxab8v7dbmYBZ39itDA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.14.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.0.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", + "license": "MIT" + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.33", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-core": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.33", + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.10", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/shared": "3.5.33" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/reactivity": "3.5.33", + "@vue/runtime-core": "3.5.33", + "@vue/shared": "3.5.33", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-ssr": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "vue": "3.5.33" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.33", + "license": "MIT", + "peer": true + }, + "node_modules/@vueuse/core": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-14.3.0.tgz", + "integrity": "sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "14.3.0", + "@vueuse/shared": "14.3.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-14.3.0.tgz", + "integrity": "sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-14.3.0.tgz", + "integrity": "sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "license": "MIT", + "peer": true + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "license": "MIT", + "peer": true + }, + "node_modules/fdir": { + "version": "6.5.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.0.10", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.33", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.33", + "@vue/compiler-sfc": "3.5.33", + "@vue/runtime-dom": "3.5.33", + "@vue/server-renderer": "3.5.33", + "@vue/shared": "3.5.33" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-chartjs": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.3.tgz", + "integrity": "sha512-jqxtL8KZ6YJ5NTv6XzrzLS7osyegOi28UGNZW0h9OkDL7Sh1396ht4Dorh04aKrl2LiSalQ84WtqiG0RIJb0tA==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "vue": "^3.0.0-0 || ^2.7.0" + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-sonner": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/vue-sonner/-/vue-sonner-2.0.9.tgz", + "integrity": "sha512-i6BokNlNDL93fpzNxN/LZSn6D6MzlO+i3qXt6iVZne3x1k7R46d5HlFB4P8tYydhgqOrRbIZEsnRd3kG7qGXyw==", + "license": "MIT", + "peerDependencies": { + "@nuxt/kit": "^4.0.3", + "@nuxt/schema": "^4.0.3", + "nuxt": "^4.0.3" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@nuxt/schema": { + "optional": true + }, + "nuxt": { + "optional": true + } + } + } + } +} diff --git a/app/frontend/package.json b/app/frontend/package.json new file mode 100644 index 0000000..f4a6bb7 --- /dev/null +++ b/app/frontend/package.json @@ -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" + } +} diff --git a/app/frontend/postcss.config.js b/app/frontend/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/app/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/app/frontend/public/favicon.svg b/app/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/app/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/frontend/public/icons.svg b/app/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/app/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/frontend/src/assets/hero.png b/app/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/app/frontend/src/assets/hero.png differ diff --git a/app/frontend/src/assets/typescript.svg b/app/frontend/src/assets/typescript.svg new file mode 100644 index 0000000..6c9d69c --- /dev/null +++ b/app/frontend/src/assets/typescript.svg @@ -0,0 +1 @@ + diff --git a/app/frontend/src/assets/vite.svg b/app/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/app/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/app/frontend/src/components/glass/GlassButton.vue b/app/frontend/src/components/glass/GlassButton.vue new file mode 100644 index 0000000..3ff12ef --- /dev/null +++ b/app/frontend/src/components/glass/GlassButton.vue @@ -0,0 +1,8 @@ + diff --git a/app/frontend/src/components/glass/GlassCard.vue b/app/frontend/src/components/glass/GlassCard.vue new file mode 100644 index 0000000..b3cab84 --- /dev/null +++ b/app/frontend/src/components/glass/GlassCard.vue @@ -0,0 +1,5 @@ + diff --git a/app/frontend/src/components/glass/GlassInput.vue b/app/frontend/src/components/glass/GlassInput.vue new file mode 100644 index 0000000..7fe3803 --- /dev/null +++ b/app/frontend/src/components/glass/GlassInput.vue @@ -0,0 +1,6 @@ + diff --git a/app/frontend/src/components/glass/GlassToggle.vue b/app/frontend/src/components/glass/GlassToggle.vue new file mode 100644 index 0000000..de09663 --- /dev/null +++ b/app/frontend/src/components/glass/GlassToggle.vue @@ -0,0 +1,22 @@ + + + diff --git a/app/frontend/src/counter.ts b/app/frontend/src/counter.ts new file mode 100644 index 0000000..9f629e8 --- /dev/null +++ b/app/frontend/src/counter.ts @@ -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) +} diff --git a/app/frontend/src/main.ts b/app/frontend/src/main.ts new file mode 100644 index 0000000..d72bbca --- /dev/null +++ b/app/frontend/src/main.ts @@ -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('#app')!.innerHTML = ` +
+
+ + TypeScript logo + Vite logo +
+
+

Get started

+

Edit src/main.ts and save to test HMR

+
+ +
+ +
+ +
+
+ +

Documentation

+

Your questions, answered

+ +
+
+ +

Connect with us

+

Join the Vite community

+ +
+
+ +
+
+` + +setupCounter(document.querySelector('#counter')!) diff --git a/app/frontend/src/style.css b/app/frontend/src/style.css new file mode 100644 index 0000000..527d4fb --- /dev/null +++ b/app/frontend/src/style.css @@ -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); + } +} diff --git a/app/frontend/tailwind.config.js b/app/frontend/tailwind.config.js new file mode 100644 index 0000000..7c24b4e --- /dev/null +++ b/app/frontend/tailwind.config.js @@ -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'), + ], +} diff --git a/app/frontend/tsconfig.json b/app/frontend/tsconfig.json new file mode 100644 index 0000000..1ab38c8 --- /dev/null +++ b/app/frontend/tsconfig.json @@ -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"] +} diff --git a/app/go.mod b/app/go.mod new file mode 100644 index 0000000..df53638 --- /dev/null +++ b/app/go.mod @@ -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 +) diff --git a/app/go.sum b/app/go.sum new file mode 100644 index 0000000..a11d184 --- /dev/null +++ b/app/go.sum @@ -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= diff --git a/app/main.go b/app/main.go new file mode 100644 index 0000000..6942596 --- /dev/null +++ b/app/main.go @@ -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) + } +} diff --git a/app/models.go b/app/models.go new file mode 100644 index 0000000..8657dd9 --- /dev/null +++ b/app/models.go @@ -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"` +} diff --git a/app/nftables.go b/app/nftables.go new file mode 100644 index 0000000..44f913c --- /dev/null +++ b/app/nftables.go @@ -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() +} diff --git a/app/plan.md b/app/plan.md new file mode 100644 index 0000000..d7c1761 --- /dev/null +++ b/app/plan.md @@ -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. diff --git a/app/webhook.go b/app/webhook.go new file mode 100644 index 0000000..f6f0606 --- /dev/null +++ b/app/webhook.go @@ -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 +} diff --git a/app/wg.go b/app/wg.go new file mode 100644 index 0000000..317d8ef --- /dev/null +++ b/app/wg.go @@ -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() +} diff --git a/app/wgrplane b/app/wgrplane new file mode 100644 index 0000000..747a6f2 Binary files /dev/null and b/app/wgrplane differ