80 Commits

Author SHA1 Message Date
datadunia b5c9b180dc feat: migrate policy firewall from iptables/ipset to nftables
- Rewrite wg-policy-lib.sh: replace ipset functions with nft helpers, add backup_nftables()
- Rewrite wg-policy-engine.sh: generate nft ruleset file, atomic load via nft -f, rollback support
- Simplify wg-policy-cleanup.sh: single nft delete table inet wg_policy
- Update wg-policy-ctl: nft commands for rules/ipset/stats/backup
- Rebuild install.sh: nftables dependency, WireGuard pre-check, PostUp/PostDown auto-integration
- Update build.sh/build.bat: match install.sh changes
- Update README.md: nftables prerequisites and references
2026-06-21 15:02:48 +07:00
datadunia eb525879f1 fix: test build
Beta Release / deploy (push) Successful in 1m2s
2026-05-09 15:19:03 +07:00
datadunia b1c7f10fec fix: test build
Beta Release / deploy (push) Successful in 1m4s
2026-05-09 14:41:26 +07:00
datadunia da09fb2e65 fix: test build
Beta Release / deploy (push) Successful in 1m3s
2026-05-09 14:34:26 +07:00
datadunia fefaf49298 fix: test build
Beta Release / deploy (push) Successful in 1m1s
2026-05-09 13:50:03 +07:00
datadunia fc05ac0725 fix: test build
Beta Release / deploy (push) Failing after 20s
2026-05-09 05:54:28 +07:00
datadunia 5bcac141d1 fix: test build
Beta Release / deploy (push) Successful in 1m4s
2026-05-09 03:48:48 +07:00
datadunia c7ad30d2df fix: test build
Beta Release / deploy (push) Successful in 1m4s
2026-05-09 02:42:52 +07:00
datadunia 93f6aaea76 feat: check and delete existing release before creating a new one
Beta Release / deploy (push) Successful in 1m5s
2026-05-09 01:57:11 +07:00
datadunia 7a448ffa3a fix: test build
Beta Release / deploy (push) Successful in 58s
2026-05-09 01:47:14 +07:00
datadunia 89443f9481 fix: test build
Beta Release / deploy (push) Successful in 49s
2026-05-09 01:27:45 +07:00
datadunia c5d3651a96 fix: test build
Beta Release / deploy (push) Failing after 1m0s
2026-05-09 01:22:33 +07:00
datadunia 548e3949ad feat: test build
Beta Release / deploy (push) Failing after 35s
2026-05-09 01:00:59 +07:00
datadunia 4b8d5008d6 refactor: extract shared CI steps to deploy_call.yaml reusable workflow
Beta Release / deploy (push) Successful in 50s
Release / deploy (push) Successful in 59s
2026-05-08 05:51:48 +07:00
datadunia 25e0cfd15e refactor: extract shared CI steps to deploy_call.yaml reusable workflow
Release / deploy (push) Has been cancelled
Beta Release / deploy (push) Has been cancelled
2026-05-08 05:48:02 +07:00
datadunia 9c771f098c refactor: extract shared CI steps to deploy_call.yaml reusable workflow
Beta Release / deploy (push) Successful in 59s
Release / deploy (push) Failing after 56s
2026-05-08 05:41:06 +07:00
datadunia dfc8d9ed57 fix: restore Create Release step with correct Swagger-compliant JSON and auth header
Release / build-and-release (push) Successful in 1m0s
2026-05-08 05:04:54 +07:00
datadunia 565d7f1a35 fix: restore Create Release step with correct Swagger-compliant JSON and auth header
Release / build-and-release (push) Failing after 54s
2026-05-08 05:00:55 +07:00
datadunia 45c7788d1d fix: restore Create Release step with correct Swagger-compliant JSON and auth header
Release / build-and-release (push) Failing after 52s
2026-05-08 04:55:29 +07:00
datadunia b9fbe1430f fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 04:30:35 +07:00
datadunia 5eec7651da fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 04:26:27 +07:00
datadunia c8cf771bfa fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 59s
2026-05-08 04:22:21 +07:00
datadunia ba7b9ac64c fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 04:19:51 +07:00
datadunia c42683e968 fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 56s
2026-05-08 04:14:57 +07:00
datadunia 4a3d099ff9 fix: use printf for JSON body and Authorization header for Gitea API
Release / build-and-release (push) Failing after 56s
2026-05-08 04:03:52 +07:00
datadunia 64e8372fc1 fix: use token query param and form-data upload per Gitea API spec
Release / build-and-release (push) Failing after 58s
2026-05-08 03:17:40 +07:00
datadunia d7b1de1885 fix: use curl -u x-token auth for Gitea API
Release / build-and-release (push) Failing after 53s
2026-05-08 03:10:03 +07:00
datadunia 7d0c0c18d0 fix: use curl for Gitea release API instead of broken action
Release / build-and-release (push) Failing after 55s
2026-05-08 03:02:30 +07:00
datadunia 8d237b293f fix: Action TOKEN
Release / build-and-release (push) Failing after 55s
2026-05-08 02:49:43 +07:00
datadunia 43477a3333 fix: install jq if missing, restore jq for changelog in latest.json
Release / build-and-release (push) Failing after 1m10s
2026-05-08 02:20:55 +07:00
datadunia 42a35302b3 fix: pass token via env GITHUB_TOKEN and GITEA_TOKEN for release action
Release / build-and-release (push) Failing after 54s
2026-05-08 02:18:01 +07:00
datadunia 6d9fa80b54 update app submodule
Release / build-and-release (push) Failing after 55s
2026-05-08 02:05:13 +07:00
datadunia a59ebd4813 fix: Action TOKEN 2026-05-08 02:01:46 +07:00
datadunia 0a7bf72d6c fix: use akkuman/gitea-release-action from GitHub with server_url
Release / build-and-release (push) Failing after 56s
2026-05-08 01:52:48 +07:00
datadunia 861d910363 fix: use gitea-release-action for native Gitea release support
Release / build-and-release (push) Failing after 4s
2026-05-08 01:47:49 +07:00
datadunia d0d529450c fix: use softprops/action-gh-release and add cleanup step
Release / build-and-release (push) Failing after 1m16s
2026-05-08 01:35:05 +07:00
datadunia 0c6bac4c22 fix: use tag refs instead of commit SHA for all actions
Release / build (amd64) (push) Successful in 51s
Release / build (arm64) (push) Successful in 44s
2026-05-08 00:17:23 +07:00
datadunia 7b02527200 fix: downgrade upload-artifact v4 to v3 for Gitea compatibility
Release / build (amd64) (push) Failing after 56s
Release / build (arm64) (push) Failing after 4s
2026-05-08 00:06:16 +07:00
datadunia fa4bf318b6 chore: comment out debug submodule step
Release / build (amd64) (push) Failing after 41s
Release / build (arm64) (push) Failing after 37s
2026-05-07 23:19:56 +07:00
datadunia cdd22860b3 fix: remove generate-latest-json job, update app submodule
Release / build (amd64) (push) Failing after 2m23s
Release / build (arm64) (push) Failing after 1m3s
2026-05-07 22:30:03 +07:00
datadunia bbae401002 fix: Action TOKEN
Release / build (amd64) (push) Failing after 30s
Release / build (arm64) (push) Failing after 23s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:21:50 +07:00
datadunia fb198f64bb fix: Action TOKEN
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:18:52 +07:00
datadunia d62936d607 fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:16:54 +07:00
datadunia 3b0f478d3a fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:14:10 +07:00
datadunia b39d7a4bd2 fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:07:54 +07:00
datadunia 81af99dfc4 fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:05:04 +07:00
datadunia 139463855d fix: Action TOKEN
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 14:02:45 +07:00
datadunia 309a3fea8f fix: Action TOKEN
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 13:52:59 +07:00
datadunia 1fdb8135db fix: shallow
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 13:45:26 +07:00
datadunia 22b9133431 fix: shallow
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 13:43:36 +07:00
datadunia 6e9d389cbf fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 12:53:35 +07:00
datadunia 644c6e2dde fix: shallow 2026-05-07 12:51:55 +07:00
datadunia 1716747c09 fix: shallow
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 12:48:27 +07:00
datadunia 47abc63658 fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 11:41:42 +07:00
datadunia 44c480a89a fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 11:10:14 +07:00
datadunia 35dcc2f15f fix: shallow 2026-05-07 11:08:53 +07:00
datadunia f71ef8c3e4 fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 06:23:02 +07:00
datadunia 6308017b24 fix: shallow
Release / build (amd64) (push) Failing after 4s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-07 06:20:45 +07:00
datadunia 5b4f91e6e0 fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 5s
Release / generate-latest-json (push) Has been skipped
2026-05-07 06:16:39 +07:00
datadunia f6af1fb6d8 fix: shallow 2026-05-07 05:16:39 +07:00
datadunia f47b50b857 fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-07 05:10:19 +07:00
datadunia e2c807dc12 fix: shallow
Release / build (amd64) (push) Failing after 13s
Release / build (arm64) (push) Failing after 9s
2026-05-07 04:35:06 +07:00
datadunia 144be82e9b fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 04:32:28 +07:00
datadunia 0feed113ac fix: shallow
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 04:19:49 +07:00
datadunia 20478191a3 fix: shallow
Release / build (amd64) (push) Failing after 8s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 02:17:39 +07:00
datadunia 32f1e78ee1 fix: shallow
Release / build (amd64) (push) Failing after 5s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 02:16:24 +07:00
datadunia ac32038ebb fix: shallow clone with timeout to prevent hang
Release / build (amd64) (push) Failing after 8s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-07 02:14:26 +07:00
datadunia 417fd99dbf fix: use repo-local http.extraHeader for submodule auth
Release / build (arm64) (push) Has been cancelled
Release / generate-latest-json (push) Has been cancelled
Release / build (amd64) (push) Has been cancelled
2026-05-07 01:52:21 +07:00
datadunia 1633fede7a fix: manual clone with http.extraheader reset for host mode
Release / build (amd64) (push) Failing after 6s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-07 01:12:45 +07:00
datadunia 14742b4c27 fix: use submodules: true for checkout
Release / build (amd64) (push) Failing after 33s
Release / build (arm64) (push) Failing after 47s
Release / generate-latest-json (push) Has been skipped
2026-05-06 23:43:51 +07:00
datadunia c49964d4d4 fix: use SSH for submodule auth 2026-05-06 17:52:25 +07:00
datadunia 4c00af7c44 fix: checkout with submodules false + manual auth
Release / build (amd64) (push) Failing after 41s
Release / build (arm64) (push) Failing after 33s
Release / generate-latest-json (push) Has been skipped
2026-05-06 17:33:20 +07:00
datadunia de21d422b6 fix: use git pull + submodule update instead of actions/checkout
Release / build (amd64) (push) Failing after 4s
Release / build (arm64) (push) Failing after 4s
Release / generate-latest-json (push) Has been skipped
2026-05-06 17:20:49 +07:00
datadunia 581350971a fix: use secrets for submodule auth
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 6s
Release / generate-latest-json (push) Has been skipped
2026-05-06 16:40:09 +07:00
datadunia 8a8927c8e8 fix: submodule checkout with token auth
Release / build (amd64) (push) Failing after 7s
Release / generate-latest-json (push) Has been skipped
Release / build (arm64) (push) Failing after 6s
2026-05-06 16:21:55 +07:00
datadunia 282b2a3b69 fix: properly initialize submodules in workflow
Release / build (amd64) (push) Failing after 7s
Release / build (arm64) (push) Failing after 7s
Release / generate-latest-json (push) Has been skipped
2026-05-06 16:15:16 +07:00
datadunia fc1e5513aa workflow fix 2026-05-06 16:05:05 +07:00
datadunia 481807769e feat: add Gitea CI/CD workflows and update documentation
Release / build (amd64) (push) Failing after 10s
Release / build (arm64) (push) Failing after 11s
Release / generate-latest-json (push) Has been skipped
- Add .gitea/workflows/release.yaml for multi-arch build (amd64, arm64)
- Generate latest.json for auto-update system
- Update AGENTS.md and README.md for WGRplane integration
- Update app/ submodule reference to latest commit (2db8e5a)
2026-05-06 05:27:58 +07:00
datadunia 52e629ac02 docs: update AGENTS.md and README.md for WGRplane integration
- Add WGRplane component documentation to AGENTS.md
- Update README.md with combined WireGuard Policy + WGRplane docs
- Fix .gitignore (remove .gitea/ from ignore)
2026-05-06 05:23:00 +07:00
datadunia b3676b87cb feat: update WGRplane submodule to latest version
- Update app/ submodule to commit ff80d9c
- Complete WGRplane with Vue.js frontend and Go backend
- Includes Glassmorphism UI, webhooks, i18n, and plugins
2026-05-05 03:35:52 +07:00
14 changed files with 1228 additions and 844 deletions
+15
View File
@@ -0,0 +1,15 @@
name: Beta Release
on:
push:
tags:
- 'v*-beta*'
- 'v*-test*'
jobs:
deploy:
if: "contains(github.ref_name, 'beta') || contains(github.ref_name, 'test')"
uses: ./.gitea/workflows/deploy_call.yaml
with:
prerelease: true
secrets: inherit
+136
View File
@@ -0,0 +1,136 @@
name: Deploy
on:
workflow_call:
inputs:
prerelease:
description: 'Mark as prerelease'
required: false
type: boolean
default: false
permissions:
contents: write
jobs:
build-and-release:
runs-on: ubuntu-latest
steps:
- name: Clone repository with submodules
run: |
git config --global --remove-section http || true
git config --global --unset-all core.askPass || true
TOKEN="${{ secrets.BUILD_TOKEN }}"
git clone --recurse-submodules \
-c credential.helper="" \
https://token:$TOKEN@git.datadunia.com/devops/wireguard-vpn.git .
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.22'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Build Vue frontend
run: |
cd app/frontend
npm install
npm run build
- name: Build Go binaries
run: |
cd app
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o ../wgrplane-linux-amd64 .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o ../wgrplane-linux-arm64 .
- name: Generate latest.json
run: |
VERSION="${{ gitea.ref_name }}"
RELEASE_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')"
REPO="${{ gitea.repository }}"
SERVER="${{ gitea.server_url }}"
cat > latest.json << ENDJSON
{
"version": "$VERSION",
"release_date": "$RELEASE_DATE",
"download_urls": {
"amd64": "${SERVER}/${REPO}/releases/download/${VERSION}/wgrplane-linux-amd64",
"arm64": "${SERVER}/${REPO}/releases/download/${VERSION}/wgrplane-linux-arm64"
}
}
ENDJSON
- name: Create Release and upload assets
env:
TOKEN: ${{ secrets.BUILD_TOKEN }}
run: |
if [ -z "$TOKEN" ]; then
echo "Value: [EMPTY]"
exit 1
else
echo "Length: ${#TOKEN} characters"
fi
REPO="${{ gitea.repository }}"
TAG="${{ gitea.ref_name }}"
API="${{ gitea.server_url }}/api/v1"
# 0. Check & Delete Existing Release
echo "=== 0. Check & Delete Existing Release ==="
EXISTING_RESP=$(curl -s -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/tags/$TAG")
EXISTING_ID=$(echo "$EXISTING_RESP" | grep -o '"id":[0-9]*' | head -n 1 | cut -d':' -f2 || true)
if [ -n "$EXISTING_ID" ] && [ "$EXISTING_ID" != "null" ]; then
echo "⚠️ Found existing release for tag $TAG with ID: $EXISTING_ID. Deleting..."
DELETE_RESP=$(curl -s -w "\n%{http_code}" -X DELETE -H "Authorization: token $TOKEN" "$API/repos/$REPO/releases/$EXISTING_ID")
echo "✅ Delete response: $DELETE_RESP"
else
echo "No existing release found for $TAG. Proceeding..."
fi
# 1. Create Release
echo "=== 1. Create New Release ==="
JSON_BODY=$(printf '{"tag_name":"%s","name":"%s","body":"Release %s","draft":false,"prerelease":%s}' "$TAG" "$TAG" "$TAG" "${{ inputs.prerelease }}")
RELEASE_RESP=$(curl -s -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d "$JSON_BODY" \
"$API/repos/$REPO/releases")
# Ambil ID dengan lebih teliti
# Tambahkan || true agar grep tidak membuat script crash (karena set -e) jika id tidak ditemukan
RELEASE_ID=$(echo "$RELEASE_RESP" | grep -o '"id":[0-9]*' | head -n 1 | cut -d':' -f2 || true)
if [ -z "$RELEASE_ID" ] || [ "$RELEASE_ID" = "null" ]; then
echo "Gagal membuat release. Response: $RELEASE_RESP"
exit 1
fi
echo "Release ID: $RELEASE_ID"
# 2. Upload Assets
for FILE in wgrplane-linux-amd64 wgrplane-linux-arm64 latest.json; do
if [ -f "$FILE" ]; then
echo "Uploading $FILE..."
curl -s -X POST \
-H "Authorization: token $TOKEN" \
-F "attachment=@$FILE" \
"$API/repos/$REPO/releases/$RELEASE_ID/assets?name=$FILE"
else
echo "Skip $FILE (tidak ditemukan)"
fi
done
- name: Cleanup build artifacts
if: always()
run: |
git config --global --remove-section http || true
git config --global --unset-all core.askPass || true
rm -f wgrplane-linux-amd64 wgrplane-linux-arm64 latest.json
rm -rf app/frontend/node_modules app/frontend/dist
echo "Cleanup done"
+14
View File
@@ -0,0 +1,14 @@
name: Release
on:
push:
tags:
- 'v[0-9]*.[0-9]*.[0-9]'
jobs:
deploy:
if: "!contains(github.ref_name, 'beta') && !contains(github.ref_name, 'test')"
uses: ./.gitea/workflows/deploy_call.yaml
with:
prerelease: false
secrets: inherit
+1
View File
@@ -1 +1,2 @@
.test/ .test/
.sisyphus/
+111 -22
View File
@@ -1,27 +1,116 @@
# WireGuard Policy Firewall (`03.wireguard-policy`) # AGENTS.md -- WireGuard Policy Firewall + WGRplane
## Architecture & Configuration Flow **Generated:** 2026-05-08
- **Goal:** Dynamic iptables/ipset rules based on WireGuard configuration (`wg0.conf`). **Project:** 03.wireguard-policy
- **Data Flow:** `wg0.conf` -> `wg-sync-policy.sh` -> `policy.json` -> `wg-policy-engine.sh` -> `iptables`/`ipset`
- **File Watcher:** `wg-sync-watch.sh` monitors `wg0.conf` via `inotifywait` and debounces changes to re-run the sync and engine.
## Critical Parsing Rules & Design Constraints ## OVERVIEW
- **Target IPs Parsing (`#Access`):** The firewall script uses the custom `#Access` comment in `wg0.conf` to define egress/firewall whitelists for clients. Hybrid project: (1) WireGuard Policy Firewall -- shell script iptables/ipset engine driven by `wg0.conf` `#Access` comments. (2) WGRplane -- Go binary (port 10087) serving REST API + Vue 3 SPA for WireGuard control plane management.
- **Why `#Access` is mandatory:** WireGuard's native `AllowedIPs` on a Server dictates *routing* towards the client. If we put target destinations in the Server's `AllowedIPs`, the Server would wrongly route traffic destined for those IPs *into* the client tunnel. Therefore, a custom `#Access` comment is the only correct way to define firewall whitelist destinations without breaking WireGuard's Cryptokey Routing.
- **Do not remove `#Access`:** Future agents MUST NOT attempt to refactor the script to parse targets from `AllowedIPs`. It is architecturally incorrect for this use case.
## Testing & Verifying ## STRUCTURE
- `wg-policy-ctl status`: Check the overall health, including interface status, JSON validity, lock files, and iptables rules counts. ```
- `wg-policy-ctl validate`: Validates `policy.json` without applying. 03.wireguard-policy/
- `wg-policy-ctl rules`: View the applied iptables rules in the active chain (`WG_POLICY`). ├── app/ # WGRplane: Go backend + Vue 3 frontend (see app/AGENTS.md)
- `wg-policy-ctl reload`: Forces a re-sync from `wg0.conf` and re-applies iptables. │ ├── *.go # 13 Go files, flat package (no subdirs)
│ ├── frontend/ # Vue 3 SPA (see app/frontend/src/AGENTS.md)
│ └── docs/ # Swagger auto-generated (DO NOT EDIT)
├── wg-sync-policy.sh # Parses wg0.conf → policy.json (atomic write + flock)
├── wg-policy-engine.sh # Reads policy.json → iptables/ipset WG_POLICY chain
├── wg-sync-watch.sh # inotifywait daemon, debounces wg0.conf changes
├── wg-policy-lib.sh # Shared shell library (source only, never execute directly)
├── wg-policy-ctl # CLI wrapper for operator use
├── wg-policy-cleanup.sh # PostDown cleanup (run by WireGuard)
├── *.service / *.timer # systemd units for daemon + health check
├── install.sh # Unified installer (embeds all scripts, built by build.sh)
├── build.sh / build.bat # Rebuilds install.sh from source scripts
├── Dockerfile # Multi-stage: Go build + frontend build
└── README.md # Full user documentation
```
## Script Constraints & Gotchas ## WHERE TO LOOK
- **Atomic Operations:** Always use atomic writes (`mv -f tmp target`) for `policy.json` to prevent the policy engine from reading partial files. | Task | Location |
- **Locking:** `wg-sync-policy.sh` uses file-based locking (`flock`) to prevent race conditions during updates. |------|----------|
- **Rollback:** `wg-policy-engine.sh` creates a backup chain (`WG_POLICY_BAK`) and uses a trap on `ERR` to rollback if applying rules fails halfway. | Policy firewall logic | `wg-sync-policy.sh`, `wg-policy-engine.sh`, `wg-policy-lib.sh` |
- **Dependencies:** Requires `jq` and `inotify-tools`. | Firewall rule chain | `wg-policy-engine.sh` -- WG_POLICY iptables chain |
| Policy JSON schema | `wg-sync-policy.sh` output / `wg-policy-engine.sh` input |
| WGRplane API handlers | `app/handlers.go` |
| WGRplane DB models | `app/models.go` |
| Auth middleware | `app/auth.go` (CAUTION: see known issues) |
| nftables rules (forward mode) | `app/nftables.go` |
| Webhook delivery | `app/webhook.go` |
| Vue frontend | `app/frontend/src/` |
| i18n translations | `app/frontend/src/i18n/locales/` (en, id, zh) |
| Backend i18n | `app/active.{en,id,zh}.json` |
| Systemd service config | `wg-policy.service`, `wgrplane.service` |
## Development Commands ## CRITICAL DESIGN RULES
- Restart the watcher service: `systemctl restart wg-policy.service`
- Check service logs: `journalctl -u wg-policy.service -f` ### Policy Firewall -- `#Access` MUST NOT be changed
- WireGuard `AllowedIPs` on the server side = Cryptokey Routing, not firewall whitelist
- Putting destination IPs in server's `AllowedIPs` breaks routing (WG tunnels those packets INTO client)
- `#Access` comment is the ONLY correct way to declare firewall destinations per-peer
- **NEVER refactor to parse targets from `AllowedIPs`** -- architecturally incorrect
### Atomic Writes
- Always `mv -f tmp target` for `policy.json` -- never write directly
- `wg-sync-policy.sh` uses `flock` -- never bypass locking
### Rollback
- `wg-policy-engine.sh` creates `WG_POLICY_BAK` chain; traps `ERR` for rollback
### No SaveConfig
- WireGuard `SaveConfig = true` strips ALL comments including `#Access` -- NEVER enable
## KNOWN ISSUES / GOTCHAS
- **AuthMiddleware NOT applied**: `auth.go` defines `AuthMiddleware` but it is NOT wired to any routes in `main.go`. All API endpoints currently unprotected (auth header still checked inside handlers via manual if-check, but middleware chain is absent).
- **TOTP secrets in-memory**: `totpSecrets` map in `auth.go` is not persisted; lost on restart.
- **WebSocket stats are MOCK**: `stats.go` broadcasts randomly generated numbers, not real WireGuard traffic.
- **Plugin system is stub**: `plugins.go` TelegramNotifier/SlackNotifier just print to stdout.
- **Binary + DB in app/**: `wgrplane` binary and `wgrplane.db` live in `app/` (non-standard, intentional).
- **Duplicate i18n**: `app/active.*.json` (backend i18n) and `app/frontend/src/i18n/locales/` (frontend i18n) are separate systems.
## ANTI-PATTERNS
- Never parse firewall targets from `AllowedIPs` -- use `#Access` only
- Never write `policy.json` without atomic mv + flock
- Never run `wg-policy-lib.sh` directly (source-only library)
- Never enable `SaveConfig = true` in wg0.conf
- Do NOT edit `app/docs/docs.go` -- auto-generated by swaggo
- Do NOT put business logic in `app/main.go` -- it's bootstrap only
## COMMANDS
### Policy Firewall
```bash
wg-policy-ctl status # Health: interface, JSON validity, rule counts
wg-policy-ctl validate # Validate policy.json without applying
wg-policy-ctl rules # Show active WG_POLICY iptables rules
wg-policy-ctl reload # Force re-sync from wg0.conf + re-apply
wg-policy-ctl policy # Show raw policy.json
wg-policy-ctl log # Show dropped packet logs (rate-limited)
systemctl restart wg-policy.service
journalctl -u wg-policy.service -f
```
### WGRplane (Go App)
```bash
cd app && go build -o ../wgrplane . # Build Go binary
cd app/frontend && npm install && npm run build # Build Vue frontend
./wgrplane # Run (serves :10087)
systemctl restart wgrplane.service
journalctl -u wgrplane.service -f
curl -H "wg-rplane-datadunia: test-api-key" http://localhost:10087/api/servers
```
### Installer
```bash
./build.sh # Rebuild install.sh from source scripts (Linux)
./build.bat # Rebuild install.sh from source scripts (Windows)
sudo ./install.sh install # Full install (deps + scripts + systemd + service)
sudo ./install.sh uninstall # Remove all
```
## DEPENDENCIES
- Shell: `jq`, `inotify-tools` (required); `ipset` (optional, O(1) lookup)
- Go: 1.25.1, SQLite (glebarez/sqlite), Gorilla Mux, GORM, JWT, TOTP, WebSocket
- Frontend: Vue 3, TypeScript, Vite, TailwindCSS 4, vue-i18n 9
- Auth headers: `wg-rplane-datadunia: <KEY>` (API key) | `Authorization: Bearer <JWT>` | `X-TOTP: <CODE>`
- Env vars: `WG_API_KEY` (default: test-api-key), `JWT_SECRET`, `WG_RPLANE_MODE` (forward|standalone), `APP_FRONTEND_DIR`
+253 -10
View File
@@ -1,8 +1,29 @@
# WireGuard Dynamic Policy Firewall # WireGuard Policy Firewall + WGRplane Control Plane
A lightweight, robust, and highly dynamic iptables/ipset policy firewall engine designed to restrict and control WireGuard peer traffic (egress traffic mapping) straight from `wg0.conf`. A complete WireGuard management solution combining two powerful components:
Rather than allowing all VPN clients to reach any part of your internal network, this tool isolates clients from each other by default and reads a custom `#Access` comment inside `wg0.conf` to automatically generate strict `iptables` rules and `ipset` whitelists per-client on the fly. 1. **WireGuard Dynamic Policy Firewall** - A lightweight, robust nftables policy engine that restricts and controls WireGuard peer traffic directly from `wg0.conf` using custom `#Access` comments.
2. **WGRplane** - A Go-native control plane application with Vue 3 frontend, providing a modern web dashboard for WireGuard management with real-time monitoring, peer CRUD, and hybrid firewall enforcement.
---
## 📑 Table of Contents
### Policy Firewall (Shell Scripts)
- [Architecture & Data Flow](#-architecture--data-flow)
- [Installation](#-installation)
- [Prerequisites](#-prerequisites)
- [wg0.conf Integration](#-integrasi-ke-wg0conf)
- [CLI Usage](#-wg-policy-ctl-cli-usage)
- [Systemd Integration](#-systemd-integration-watcher-daemon)
### WGRplane (Go App)
- [WGRplane Overview](#-wgrplane-overview)
- [WGRplane Architecture](#-wgrplane-architecture)
- [WGRplane Features](#-wgrplane-features)
- [WGRplane Tech Stack](#-wgrplane-tech-stack)
- [WGRplane API Endpoints](#-wgrplane-api-endpoints)
- [WGRplane Installation](#-wgrplane-installation)
--- ---
@@ -10,7 +31,7 @@ Rather than allowing all VPN clients to reach any part of your internal network,
1. **`wg0.conf`**: The standard WireGuard configuration. Contains standard `[Peer]` configs alongside a custom `#Access` tag. 1. **`wg0.conf`**: The standard WireGuard configuration. Contains standard `[Peer]` configs alongside a custom `#Access` tag.
2. **`wg-sync-policy.sh`**: Safely parses `wg0.conf` and generates a structured `/etc/wireguard/policy.json` atomically. 2. **`wg-sync-policy.sh`**: Safely parses `wg0.conf` and generates a structured `/etc/wireguard/policy.json` atomically.
3. **`wg-policy-engine.sh`**: Reads `policy.json` to generate robust rules, applying `iptables` and `ipset` directly to the system. 3. **`wg-policy-engine.sh`**: Reads `policy.json` to generate robust rules, applying nftables directly to the system.
4. **Watcher Daemon**: Monitors `wg0.conf` for changes via `inotifywait` and triggers the pipeline seamlessly when updates are made. 4. **Watcher Daemon**: Monitors `wg0.conf` for changes via `inotifywait` and triggers the pipeline seamlessly when updates are made.
--- ---
@@ -49,9 +70,7 @@ build.bat
|---------|----------|---------| |---------|----------|---------|
| `jq` | **Yes** | `apt install jq` | | `jq` | **Yes** | `apt install jq` |
| `inotify-tools` | **Yes** (for watcher daemon) | `apt install inotify-tools` | | `inotify-tools` | **Yes** (for watcher daemon) | `apt install inotify-tools` |
| `ipset` | Optional | `apt install ipset` | | `nftables` | **Yes** | `apt install nftables` |
If `ipset` is not installed, the engine will automatically fall back to per-rule `iptables` whitelist entries. This works fine for small deployments. For large numbers of clients/targets, `ipset` is recommended for O(1) lookup performance.
--- ---
@@ -108,7 +127,7 @@ WireGuard uses `AllowedIPs` for Cryptokey Routing (deciding which tunnel interfa
## 🛠 `wg-policy-ctl` CLI Usage ## 🛠 `wg-policy-ctl` CLI Usage
You don't need to manually interact with `iptables` or `.json` files. Use the `wg-policy-ctl` wrapper. You don't need to manually interact with `nft` or `.json` files. Use the `wg-policy-ctl` wrapper.
```bash ```bash
# View the health of the firewall engine and active locks # View the health of the firewall engine and active locks
@@ -117,10 +136,10 @@ wg-policy-ctl status
# View the raw, parsed JSON policy # View the raw, parsed JSON policy
wg-policy-ctl policy wg-policy-ctl policy
# Inspect active iptables rules # Inspect active nftables rules
wg-policy-ctl rules wg-policy-ctl rules
# Check memory sets mapping IP targets (ipset) # Check nftables set contents
wg-policy-ctl ipset wg-policy-ctl ipset
# Manually re-sync rules immediately # Manually re-sync rules immediately
@@ -171,3 +190,227 @@ Check the watcher logs:
```bash ```bash
journalctl -u wg-policy.service -f journalctl -u wg-policy.service -f
``` ```
---
## 🚀 WGRplane Overview
**WGRplane** is a Go-native WireGuard control plane application with a Vue 3 frontend, providing a modern web dashboard for WireGuard management. It features a single Go binary backend, SPA frontend, dynamic policy firewall integration, and glassmorphism UI design.
The application lives in the `/app` directory. For full documentation, see [`app/README.md`](app/README.md).
---
## 🏗 WGRplane Architecture
```
wg0.conf (with/without #Access)
┌─────────────────────────────────────────────┐
│ WGRplane (Go Binary :10087) │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Gorilla │ │ GORM │ │ nftables │ │
│ │ Mux Router│ │ SQLite │ │ Engine │ │
│ └───────────┘ └──────────┘ └──────────┘ │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Webhook │ │ Scheduler│ │ WebSocket│ │
│ │ Engine │ │ Cron │ │ Hub │ │
│ └───────────┘ └──────────┘ └──────────┘ │
│ ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Auth │ │ SMTP │ │ Plugins │ │
│ │ JWT/TOTP │ │ Email │ │ TG/Slack │ │
│ └───────────┘ └──────────┘ └──────────┘ │
└─────────────────────────────────────────────┘
↓ HTTP/WebSocket
┌─────────────────────────────────────────────┐
│ Frontend (Vue 3 + TypeScript + Tailwind) │
│ Glassmorphism UI, i18n, Dark/Light mode │
└─────────────────────────────────────────────┘
```
### Hybrid Mode
WGRplane supports two server modes:
- **`forward`** - Directly applies nftables rules on the local machine. Peer policies are enforced via `nft` commands.
- **`standalone`** - Acts as a control plane that triggers webhooks to remote WireGuard servers. Policy enforcement happens on the remote side.
### 2-Column Policy
Each peer has two independent policy columns:
| Column | Function |
|--------|----------|
| **AllowAccess** | List of CIDRs the peer can access (internal targets) |
| **AllowInternet** | Boolean flag. If `true`, peer gets unlimited internet access (MASQUERADE) |
Peers without any rules are isolated from other peers and the internet by default.
---
## ✨ WGRplane Features
- **Go-Native Architecture**: Single Go binary handles all API, database, webhooks, scheduler, and nftables. No Python/Flask needed.
- **Complete Peer CRUD**: Add, edit, delete peers. Generate QR codes for mobile client import. Export `.conf` configuration files.
- **Hybrid Mode**: `forward` mode (local nftables) or `standalone` mode (webhook to remote servers).
- **2-Column Policy UI**: "Allow Access" column (firewall whitelist CIDR) and "Allow Internet" toggle per peer.
- **Real-time Monitoring**: WebSocket broadcasts peer statistics and traffic every 5 seconds.
- **Automated Scheduling**: Daily cron jobs to delete expired peers, restrict over-limit peers, and reset monthly data usage.
- **Security**: API Key authentication (`wg-rplane-datadunia`), JWT Bearer tokens, and TOTP (2FA).
- **Webhook Engine**: Integration with remote servers (Mikrotik, etc.). Retry with exponential backoff, custom headers, Go templates.
- **Plugin System**: Telegram, Slack, and Traffic Logger notifications.
- **i18n & Themes**: Multi-language (English, Indonesian, Chinese). Dark/Light/Auto mode.
- **Glassmorphism UI**: Futuristic design with frosted glass cards, buttons, and inputs.
---
## 🛠 WGRplane Tech Stack
| Component | Technology |
|-----------|------------|
| **Backend** | Go, Gorilla Mux, GORM (SQLite via glebarez/sqlite) |
| **Frontend** | Vue 3, TypeScript, Vite, TailwindCSS 4, vue-i18n 9 |
| **Auth** | JWT (golang-jwt/v5), TOTP (pquerna/otp), API Key |
| **WebSockets** | gorilla/websocket |
| **Webhooks** | Go net/http with retry + exponential backoff |
| **Scheduling** | robfig/cron v3 |
| **QR Code** | skip2/go-qrcode |
| **Email** | jordan-wright/email (SMTP) |
| **Firewall** | Bash, nftables, inotify-tools, jq |
| **Container** | Docker (multi-stage build), docker-compose |
---
## 🌐 WGRplane API Endpoints Summary
All endpoints are served on port **10087**. For complete API documentation with request/response details, see [`app/README.md`](app/README.md) or visit `/swagger/` on your running instance.
### Authentication
| Method | Header | Notes |
|--------|--------|-------|
| API Key | `wg-rplane-datadunia: <KEY>` | Set via env var `WG_API_KEY`. Default: `test-api-key` |
| JWT | `Authorization: Bearer <TOKEN>` | Expires in 15 minutes. Secret via env var `JWT_SECRET` |
| TOTP | `X-TOTP: <CODE>` | Required if user enables TOTP |
### Main Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/servers` | `GET/POST` | List all servers / Create new server |
| `/api/servers/{id}` | `GET/PUT/DELETE` | Get/Update/Delete server |
| `/api/servers/{id}/peers` | `GET/POST` | List peers / Create new peer |
| `/api/peers/{id}` | `PUT/DELETE` | Update/Delete peer |
| `/api/peers/{id}/config` | `GET` | Download WireGuard `.conf` file |
| `/api/peers/{id}/qrcode` | `GET` | Generate QR code PNG for mobile import |
| `/api/servers/{id}/webhooks` | `GET/POST` | List/Create webhooks |
| `/api/stats` | `GET` | Global statistics |
| `/ws/stats` | WebSocket | Real-time stats broadcast (5s interval) |
| `/swagger/` | - | Interactive Swagger UI documentation |
---
## 📦 WGRplane Installation
### Option 1: Docker Compose (Recommended)
```bash
# Clone repository
git clone https://git.datadunia.com/hainzero/WGRplane.git
cd 03.wireguard-policy
# Start WGRplane and WireGuard
docker compose up -d
# Access dashboard at http://localhost:10087
```
### Option 2: Install Script
```bash
# Run automated installer (Ubuntu/Debian/CentOS)
sudo ./install.sh install
# Uninstall
sudo ./install.sh uninstall
```
### Option 3: Manual Build
```bash
# Build Go binary
cd app
go build -o ../wgrplane .
cd ..
# Build frontend
cd app/frontend
npm install && npm run build
cd ../..
# Run
./wgrplane
# Server starts on :10087
```
### Option 4: Native CLI / Systemd Service
The WGRplane binary includes a built-in CLI to manage its own systemd service.
```bash
# Check system dependencies first
./wgrplane doctor
# Install as systemd service (auto-creates unit, enables, and starts)
sudo ./wgrplane install
# View logs
journalctl -u wgrplane -f
# Other available commands:
sudo ./wgrplane stop
sudo ./wgrplane restart
sudo ./wgrplane uninstall
```
### Manual Serve & Config
To run the server manually in the foreground with custom ports:
```bash
./wgrplane serve --port 8080 --host 127.0.0.1
```
On first run, it generates a default configuration file at `~/.config/wgrplane/config.json`.
---
## 📁 Project Structure
```
03.wireguard-policy/
├── app/ # Go backend + Vue frontend
│ ├── main.go # Bootstrap server, routing, init DB
│ ├── handlers.go # REST API route handlers
│ ├── models.go # GORM models (Server, Peer, Webhook, SMTP)
│ ├── auth.go # JWT, TOTP, API key auth middleware
│ ├── nftables.go # nftables rule management (mode forward)
│ ├── webhook.go # Webhook engine with retry/backoff
│ ├── scheduler.go # Cron jobs (expiry, data limit, reset)
│ ├── stats.go # WebSocket Hub for real-time stats
│ ├── frontend/ # Vue 3 SPA (TypeScript, TailwindCSS)
│ └── docs/ # Swagger documentation
├── wg-sync-policy.sh # Parse wg0.conf → policy.json
├── wg-policy-engine.sh # Apply policy.json → nftables
├── wg-sync-watch.sh # inotifywait watcher daemon
├── wg-policy-ctl # CLI wrapper for policy management
├── wg-policy-cleanup.sh # Cleanup script for PostDown
├── wg-policy.service # Systemd unit for watcher daemon
├── wgrplane.service # Systemd unit for Go backend
├── install.sh # Automated installer (Docker + services)
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose stack
└── README.md # This file
```
For detailed WGRplane documentation including webhooks, plugins, scheduler, and frontend details, refer to [`app/README.md`](app/README.md).
+1 -1
Submodule app updated: d4462053a5...b43866d42c
+72 -2
View File
@@ -16,12 +16,78 @@ echo Building %INSTALL_SCRIPT%...
>> "%INSTALL_SCRIPT%" echo exit 1 >> "%INSTALL_SCRIPT%" echo exit 1
>> "%INSTALL_SCRIPT%" echo fi >> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if ! command -v wg ^&>/dev/null; then
>> "%INSTALL_SCRIPT%" echo echo "[ERROR] WireGuard is not installed."
>> "%INSTALL_SCRIPT%" echo echo " Debian/Ubuntu: apt install wireguard"
>> "%INSTALL_SCRIPT%" echo echo " RHEL/CentOS: dnf install wireguard-tools"
>> "%INSTALL_SCRIPT%" echo echo " Arch: pacman -S wireguard-tools"
>> "%INSTALL_SCRIPT%" echo exit 1
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if [[ ! -f /etc/wireguard/wg0.conf ]]; then
>> "%INSTALL_SCRIPT%" echo echo "[WARN] WireGuard config not found: /etc/wireguard/wg0.conf"
>> "%INSTALL_SCRIPT%" echo echo " PostUp/PostDown hooks will not be added automatically."
>> "%INSTALL_SCRIPT%" echo echo " Create your wg0.conf first, then re-install."
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo WG_CONF="/etc/wireguard/wg0.conf"
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo install_hooks^(^) {
>> "%INSTALL_SCRIPT%" echo if [[ ! -f "$WG_CONF" ]]; then
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if grep -q "wg-sync-policy.sh" "$WG_CONF" 2^>/dev/null; then
>> "%INSTALL_SCRIPT%" echo echo "[OK] PostUp/PostDown hooks already present in $WG_CONF"
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Adding PostUp/PostDown hooks to $WG_CONF..."
>> "%INSTALL_SCRIPT%" echo cp "$WG_CONF" "${WG_CONF}.bak.$(date +%%Y%%m%%d%%H%%M%%S)"
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo local peer_line
>> "%INSTALL_SCRIPT%" echo peer_line=$(grep -n '^\[Peer\]' "$WG_CONF" ^| head -1 ^| cut -d: -f1)
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if [[ -n "$peer_line" ]]; then
>> "%INSTALL_SCRIPT%" echo sed -i "${peer_line}i\\
>> "%INSTALL_SCRIPT%" echo # WireGuard Policy Firewall hooks\\
>> "%INSTALL_SCRIPT%" echo PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh\\
>> "%INSTALL_SCRIPT%" echo PostDown = /usr/local/bin/wg-policy-cleanup.sh" "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo else
>> "%INSTALL_SCRIPT%" echo {
>> "%INSTALL_SCRIPT%" echo echo ""
>> "%INSTALL_SCRIPT%" echo echo "# WireGuard Policy Firewall hooks"
>> "%INSTALL_SCRIPT%" echo echo "PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh"
>> "%INSTALL_SCRIPT%" echo echo "PostDown = /usr/local/bin/wg-policy-cleanup.sh"
>> "%INSTALL_SCRIPT%" echo } ^>^> "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "[OK] Hooks added to $WG_CONF"
>> "%INSTALL_SCRIPT%" echo }
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo remove_hooks^(^) {
>> "%INSTALL_SCRIPT%" echo if [[ ! -f "$WG_CONF" ]]; then
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo if ! grep -q "wg-sync-policy.sh" "$WG_CONF" 2^>/dev/null; then
>> "%INSTALL_SCRIPT%" echo return 0
>> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Removing PostUp/PostDown hooks from $WG_CONF..."
>> "%INSTALL_SCRIPT%" echo cp "$WG_CONF" "${WG_CONF}.bak.$(date +%%Y%%m%%d%%H%%M%%S)"
>> "%INSTALL_SCRIPT%" echo sed -i '/# WireGuard Policy Firewall hooks/d' "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo sed -i '/wg-sync-policy\.sh/d' "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo sed -i '/wg-policy-cleanup\.sh/d' "$WG_CONF"
>> "%INSTALL_SCRIPT%" echo echo "[OK] Hooks removed from $WG_CONF"
>> "%INSTALL_SCRIPT%" echo }
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo install_policy^(^) { >> "%INSTALL_SCRIPT%" echo install_policy^(^) {
>> "%INSTALL_SCRIPT%" echo echo "Installing WireGuard Policy Firewall..." >> "%INSTALL_SCRIPT%" echo echo "Installing WireGuard Policy Firewall..."
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Checking dependencies..." >> "%INSTALL_SCRIPT%" echo echo "Checking dependencies..."
>> "%INSTALL_SCRIPT%" echo apt-get update -y ^|^| true >> "%INSTALL_SCRIPT%" echo apt-get update -y ^|^| true
>> "%INSTALL_SCRIPT%" echo apt-get install -y jq inotify-tools ipset iptables ^|^| true >> "%INSTALL_SCRIPT%" echo apt-get install -y jq inotify-tools nftables ^|^| true
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Writing scripts to /usr/local/bin/..." >> "%INSTALL_SCRIPT%" echo echo "Writing scripts to /usr/local/bin/..."
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
@@ -48,7 +114,9 @@ call :AppendFile wg-policy-health.timer /etc/systemd/system/wg-policy-health.tim
>> "%INSTALL_SCRIPT%" echo echo "Enabling and starting services..." >> "%INSTALL_SCRIPT%" echo echo "Enabling and starting services..."
>> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy.service >> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy.service
>> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy-health.timer >> "%INSTALL_SCRIPT%" echo systemctl enable --now wg-policy-health.timer
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo install_hooks
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Installation complete!" >> "%INSTALL_SCRIPT%" echo echo "Installation complete!"
>> "%INSTALL_SCRIPT%" echo echo "You can check status with: wg-policy-ctl status" >> "%INSTALL_SCRIPT%" echo echo "You can check status with: wg-policy-ctl status"
>> "%INSTALL_SCRIPT%" echo } >> "%INSTALL_SCRIPT%" echo }
@@ -64,6 +132,8 @@ call :AppendFile wg-policy-health.timer /etc/systemd/system/wg-policy-health.tim
>> "%INSTALL_SCRIPT%" echo /usr/local/bin/wg-policy-cleanup.sh ^|^| true >> "%INSTALL_SCRIPT%" echo /usr/local/bin/wg-policy-cleanup.sh ^|^| true
>> "%INSTALL_SCRIPT%" echo fi >> "%INSTALL_SCRIPT%" echo fi
>> "%INSTALL_SCRIPT%" echo. >> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo remove_hooks
>> "%INSTALL_SCRIPT%" echo.
>> "%INSTALL_SCRIPT%" echo echo "Removing systemd units..." >> "%INSTALL_SCRIPT%" echo echo "Removing systemd units..."
>> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy.service >> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy.service
>> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy-health.service >> "%INSTALL_SCRIPT%" echo rm -f /etc/systemd/system/wg-policy-health.service
+73 -3
View File
@@ -16,16 +16,82 @@ cat << 'MAIN_EOF' > "$INSTALL_SCRIPT"
set -euo pipefail set -euo pipefail
if [[ $EUID -ne 0 ]]; then if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root." echo "This script must be run as root."
exit 1 exit 1
fi fi
if ! command -v wg &>/dev/null; then
echo "[ERROR] WireGuard is not installed."
echo " Debian/Ubuntu: apt install wireguard"
echo " RHEL/CentOS: dnf install wireguard-tools"
echo " Arch: pacman -S wireguard-tools"
exit 1
fi
if [[ ! -f /etc/wireguard/wg0.conf ]]; then
echo "[WARN] WireGuard config not found: /etc/wireguard/wg0.conf"
echo " PostUp/PostDown hooks will not be added automatically."
echo " Create your wg0.conf first, then re-install."
fi
WG_CONF="/etc/wireguard/wg0.conf"
install_hooks() {
if [[ ! -f "$WG_CONF" ]]; then
return 0
fi
if grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then
echo "[OK] PostUp/PostDown hooks already present in $WG_CONF"
return 0
fi
echo "Adding PostUp/PostDown hooks to $WG_CONF..."
cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)"
local peer_line
peer_line=$(grep -n '^\[Peer\]' "$WG_CONF" | head -1 | cut -d: -f1)
if [[ -n "$peer_line" ]]; then
sed -i "${peer_line}i\\
# WireGuard Policy Firewall hooks\\
PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh\\
PostDown = /usr/local/bin/wg-policy-cleanup.sh" "$WG_CONF"
else
{
echo ""
echo "# WireGuard Policy Firewall hooks"
echo "PostUp = /usr/local/bin/wg-sync-policy.sh; /usr/local/bin/wg-policy-engine.sh"
echo "PostDown = /usr/local/bin/wg-policy-cleanup.sh"
} >> "$WG_CONF"
fi
echo "[OK] Hooks added to $WG_CONF"
}
remove_hooks() {
if [[ ! -f "$WG_CONF" ]]; then
return 0
fi
if ! grep -q "wg-sync-policy.sh" "$WG_CONF" 2>/dev/null; then
return 0
fi
echo "Removing PostUp/PostDown hooks from $WG_CONF..."
cp "$WG_CONF" "${WG_CONF}.bak.$(date +%Y%m%d%H%M%S)"
sed -i '/# WireGuard Policy Firewall hooks/d' "$WG_CONF"
sed -i '/wg-sync-policy\.sh/d' "$WG_CONF"
sed -i '/wg-policy-cleanup\.sh/d' "$WG_CONF"
echo "[OK] Hooks removed from $WG_CONF"
}
install_policy() { install_policy() {
echo "Installing WireGuard Policy Firewall..." echo "Installing WireGuard Policy Firewall..."
echo "Checking dependencies..." echo "Checking dependencies..."
apt-get update -y || true apt-get update -y || true
apt-get install -y jq inotify-tools ipset iptables || true apt-get install -y jq inotify-tools nftables || true
echo "Writing scripts to /usr/local/bin/..." echo "Writing scripts to /usr/local/bin/..."
@@ -74,7 +140,9 @@ cat << 'MAIN_EOF_END' >> "$INSTALL_SCRIPT"
echo "Enabling and starting services..." echo "Enabling and starting services..."
systemctl enable --now wg-policy.service systemctl enable --now wg-policy.service
systemctl enable --now wg-policy-health.timer systemctl enable --now wg-policy-health.timer
install_hooks
echo "Installation complete!" echo "Installation complete!"
echo "You can check status with: wg-policy-ctl status" echo "You can check status with: wg-policy-ctl status"
} }
@@ -90,6 +158,8 @@ uninstall_policy() {
/usr/local/bin/wg-policy-cleanup.sh || true /usr/local/bin/wg-policy-cleanup.sh || true
fi fi
remove_hooks
echo "Removing systemd units..." echo "Removing systemd units..."
rm -f /etc/systemd/system/wg-policy.service rm -f /etc/systemd/system/wg-policy.service
rm -f /etc/systemd/system/wg-policy-health.service rm -f /etc/systemd/system/wg-policy-health.service
+322 -411
View File
File diff suppressed because it is too large Load Diff
+2 -56
View File
@@ -9,63 +9,9 @@ source "${SCRIPT_DIR}/wg-policy-lib.sh"
main() { main() {
log_info "Starting cleanup..." log_info "Starting cleanup..."
local DEF_IF nft delete table inet wg_policy 2>/dev/null || true
DEF_IF="$(detect_default_if)" log_info "Removed nftables table inet wg_policy"
# === Base Routing Cleanup ===
while iptables -D FORWARD -o "$WG_IF" -j ACCEPT 2>/dev/null; do :; done
while iptables -t nat -D POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; do :; done
log_info "Removed base routing and NAT rules"
# === IPv4 chain cleanup ===
local removed=0
while true; do
local line=""
line=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true)
if [[ -n "$line" ]]; then
iptables -D FORWARD "$line" 2>/dev/null || break
(( removed++ ))
else
break
fi
done
if (( removed > 0 )); then
log_info "Removed $removed FORWARD references"
fi
iptables -F "$CHAIN" 2>/dev/null || true
iptables -X "$CHAIN" 2>/dev/null || true
# Cleanup backup chain too
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true
# === IPv6 chain cleanup ===
if command -v ip6tables &>/dev/null; then
while true; do
local line6=""
line6=$(ip6tables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true)
if [[ -n "$line6" ]]; then
ip6tables -D FORWARD "$line6" 2>/dev/null || break
else
break
fi
done
ip6tables -F "$CHAIN" 2>/dev/null || true
ip6tables -X "$CHAIN" 2>/dev/null || true
ip6tables -F "$CHAIN_BACKUP" 2>/dev/null || true
ip6tables -X "$CHAIN_BACKUP" 2>/dev/null || true
fi
# === ipset cleanup ===
destroy_ipset "$IPSET_V4" 2>/dev/null || true
destroy_ipset "$IPSET_V6" 2>/dev/null || true
destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true
destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true
# === Lock cleanup ===
rm -f "$LOCK_FILE" 2>/dev/null || true rm -f "$LOCK_FILE" 2>/dev/null || true
log_info "Cleanup complete" log_info "Cleanup complete"
+16 -29
View File
@@ -13,11 +13,11 @@ Usage: $(basename "$0") <command>
Commands: Commands:
status Show full health check report status Show full health check report
policy Display current policy.json formatted policy Display current policy.json formatted
rules Show current iptables rules in WG_POLICY chain rules Show current nftables rules
ipset Show ipset contents ipset Show nftables set contents
log Tail WG_DROP logs (last 50 lines) log Tail WG_DROP logs (last 50 lines)
reload Force re-sync and re-apply policy reload Force re-sync and re-apply policy
backup Manual backup of policy + iptables backup Manual backup of policy + nftables
stats Show connection and rule statistics stats Show connection and rule statistics
validate Validate policy.json without applying validate Validate policy.json without applying
help Show this help help Show this help
@@ -43,27 +43,18 @@ cmd_policy() {
} }
cmd_rules() { cmd_rules() {
echo "=== IPv4 Chain: $CHAIN ===" echo "=== Table: $NFT_TABLE_FULL ==="
if iptables -L "$CHAIN" -n -v --line-numbers 2>/dev/null; then if nft list table "$NFT_TABLE_FULL" 2>/dev/null; then
echo "" echo ""
else else
echo "(chain not found)" echo "(table not found)"
fi
echo "=== FORWARD references ==="
iptables -L FORWARD -n -v --line-numbers 2>/dev/null | grep -i "$CHAIN" || echo "(none)"
if command -v ip6tables &>/dev/null; then
echo ""
echo "=== IPv6 Chain: $CHAIN ==="
ip6tables -L "$CHAIN" -n -v --line-numbers 2>/dev/null || echo "(chain not found)"
fi fi
} }
cmd_ipset() { cmd_ipset() {
for set_name in "$IPSET_V4" "$IPSET_V6"; do for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do
echo "=== ipset: $set_name ===" echo "=== nft set: $set_name ==="
if ipset list "$set_name" 2>/dev/null; then if nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null; then
echo "" echo ""
else else
echo "(not found)" echo "(not found)"
@@ -94,7 +85,7 @@ cmd_reload() {
cmd_backup() { cmd_backup() {
backup_policy backup_policy
backup_iptables backup_nftables
log_info "Manual backup complete. Files in: $BACKUP_DIR" log_info "Manual backup complete. Files in: $BACKUP_DIR"
} }
@@ -111,18 +102,14 @@ cmd_stats() {
jq -r '.clients // {} | to_entries[] | select(.value.access | length == 0) | .key' "$POLICY_FILE" 2>/dev/null || echo "N/A" jq -r '.clients // {} | to_entries[] | select(.value.access | length == 0) | .key' "$POLICY_FILE" 2>/dev/null || echo "N/A"
echo "" echo ""
echo "=== Active iptables rules ===" echo "=== Active nftables rules ==="
iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l || echo "N/A" nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '#' || echo "N/A"
echo "" echo ""
echo "=== Drop count (since boot) ===" echo "=== nft set entries ==="
iptables -L "$CHAIN" -n -v 2>/dev/null | grep "DROP" | awk '{sum += $1} END {print sum+0, "packets dropped"}' for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do
echo ""
echo "=== ipset entries ==="
for set_name in "$IPSET_V4" "$IPSET_V6"; do
local count local count
count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c -E '^\s+[0-9a-f]' || echo 0)
echo " $set_name: $count entries" echo " $set_name: $count entries"
done done
} }
@@ -183,4 +170,4 @@ case "${1:-help}" in
stats) cmd_stats ;; stats) cmd_stats ;;
validate) cmd_validate ;; validate) cmd_validate ;;
help|*) usage ;; help|*) usage ;;
esac esac
+177 -232
View File
@@ -1,55 +1,152 @@
#!/bin/bash #!/bin/bash
# wg-policy-engine.sh — Applies iptables/ipset rules from policy.json # wg-policy-engine.sh — Applies nftables rules from policy.json
# Fixed: unquoted variables, LOG placement, LAN block targeting,
# atomic chain swap, ipset, IPv6 optional, rollback on failure
set -euo pipefail set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "${SCRIPT_DIR}/wg-policy-lib.sh" source "${SCRIPT_DIR}/wg-policy-lib.sh"
NFT_FILE="/tmp/wg-policy.nft"
NFT_BACKUP="/tmp/wg-policy-backup.nft"
# ============================================================ # ============================================================
# ROLLBACK # ROLLBACK
# ============================================================ # ============================================================
rollback() { rollback() {
log_error "ROLLBACK triggered! Restoring previous rules..." log_error "ROLLBACK triggered! Restoring previous ruleset..."
trap - ERR
# Remove new chain references nft delete table "$NFT_TABLE_FULL" 2>/dev/null || true
while true; do
local rline="" if [[ -f "$NFT_BACKUP" ]]; then
rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) if nft -f "$NFT_BACKUP" 2>/dev/null; then
if [[ -n "$rline" ]]; then log_info "Rollback: restored from backup"
iptables -D FORWARD "$rline" 2>/dev/null || break
else else
break log_error "Rollback: failed to restore from backup"
fi fi
done fi
}
# Flush and remove new chain # ============================================================
iptables -F "$CHAIN" 2>/dev/null || true # RULESET GENERATION
iptables -X "$CHAIN" 2>/dev/null || true # ============================================================
# Restore backup chain if it exists generate_ruleset() {
if iptables -L "$CHAIN_BACKUP" -n &>/dev/null; then local WG_SUBNET="$1"
# Rename backup chain to active local WG_SUBNET_V6="$2"
iptables -N "$CHAIN" 2>/dev/null || iptables -F "$CHAIN" local LAN_SUBNETS="$3"
# Copy rules from backup local DEF_IF="$4"
iptables-save -c | grep "^-A $CHAIN_BACKUP" | \
sed "s/-A $CHAIN_BACKUP/-A $CHAIN/" | \
iptables-restore -c 2>/dev/null || true
iptables -A FORWARD -i "$WG_IF" -j "$CHAIN" cat > "$NFT_FILE" << 'HEADER'
log_info "Rollback: restored from backup chain" #!/usr/sbin/nft -f
flush ruleset
table inet wg_policy {
set wg_allowed_v4 {
type ipv4_addr . ipv4_addr
flags interval
}
set wg_allowed_v6 {
type ipv6_addr . ipv6_addr
flags interval
}
chain forward {
type filter hook forward priority filter; policy accept;
ct state established,related accept
iifname "wg0" ip saddr . ip daddr @wg_allowed_v4 accept
iifname "wg0" ip6 saddr . ip6 daddr @wg_allowed_v6 accept
HEADER
# Client isolation (IPv4)
if [[ -n "$WG_SUBNET" ]]; then
cat >> "$NFT_FILE" << EOF
/* isolation: WG client to WG client */
iifname "wg0" ip saddr $WG_SUBNET ip daddr $WG_SUBNET ct state new drop
EOF
fi fi
# Cleanup backup chain # Client isolation (IPv6)
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true if [[ -n "$WG_SUBNET_V6" ]]; then
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true cat >> "$NFT_FILE" << EOF
# Cleanup backup ipsets /* IPv6 isolation */
destroy_ipset "${IPSET_V4}_bak" 2>/dev/null || true iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr $WG_SUBNET_V6 ct state new drop
destroy_ipset "${IPSET_V6}_bak" 2>/dev/null || true iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr fe80::/10 drop
iifname "wg0" ip6 saddr $WG_SUBNET_V6 ip6 daddr fc00::/7 drop
EOF
fi
# LAN block (IPv4)
if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then
local lan_list=""
while IFS= read -r subnet; do
[[ -z "$subnet" ]] && continue
[[ "$subnet" == "$WG_SUBNET" ]] && continue
if [[ -n "$lan_list" ]]; then
lan_list+=", $subnet"
else
lan_list="$subnet"
fi
done <<< "$LAN_SUBNETS"
if [[ -n "$lan_list" ]]; then
cat >> "$NFT_FILE" << EOF
/* LAN block */
iifname "wg0" ip saddr $WG_SUBNET ip daddr { $lan_list } drop
EOF
fi
fi
# Internet access per client (inline in chain)
while IFS= read -r client_ip; do
[[ -z "$client_ip" ]] && continue
cat >> "$NFT_FILE" << EOF
/* internet access: $client_ip */
iifname "wg0" ip saddr $client_ip accept
EOF
done < <(jq -r '
.clients // {} | to_entries[] |
select(.value.internet == "true") |
.key
' "$POLICY_FILE" 2>/dev/null)
while IFS= read -r client_ip; do
[[ -z "$client_ip" ]] && continue
cat >> "$NFT_FILE" << EOF
/* internet access v6: $client_ip */
iifname "wg0" ip6 saddr $client_ip accept
EOF
done < <(jq -r '
.clients // {} | to_entries[] |
select(.value.internet == "true") |
select(.key | test(":")) |
.key
' "$POLICY_FILE" 2>/dev/null)
# Log + drop (final rule)
cat >> "$NFT_FILE" << EOF
/* log + drop */
iifname "wg0" limit rate ${LOG_RATE} log prefix "${LOG_PREFIX}: " drop
}
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
oifname "${DEF_IF}" masquerade
}
}
EOF
} }
# ============================================================ # ============================================================
@@ -75,8 +172,8 @@ main() {
exit 1 exit 1
fi fi
# Backup iptables state # Backup current nftables state
backup_iptables backup_nftables
# Set trap for rollback on failure # Set trap for rollback on failure
trap 'rollback' ERR trap 'rollback' ERR
@@ -105,221 +202,69 @@ main() {
fi fi
# === BASE ROUTING & NAT === # === BASE ROUTING & NAT ===
# Enable IP Forwarding
sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true sysctl -w net.ipv4.ip_forward=1 >/dev/null 2>&1 || true
if command -v ip6tables &>/dev/null; then sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null 2>&1 || true
# === BACKUP EXISTING TABLE ===
if nft list table "$NFT_TABLE_FULL" &>/dev/null; then
nft list table "$NFT_TABLE_FULL" > "$NFT_BACKUP" 2>/dev/null || true
log_info "Backed up existing table to $NFT_BACKUP"
fi fi
# Setup MASQUERADE on default interface # === DELETE OLD TABLE ===
if ! iptables -t nat -C POSTROUTING -o "$DEF_IF" -j MASQUERADE 2>/dev/null; then nft delete table "$NFT_TABLE_FULL" 2>/dev/null || true
iptables -t nat -A POSTROUTING -o "$DEF_IF" -j MASQUERADE
log_info "Enabled IPv4 MASQUERADE on $DEF_IF"
fi
# === CLEANUP OLD CHAIN (loop until all references removed) === # === GENERATE AND LOAD NEW RULESET ===
log_info "Cleaning up old chain references..." generate_ruleset "$WG_SUBNET" "$WG_SUBNET_V6" "$LAN_SUBNETS" "$DEF_IF"
while true; do log_info "Generated ruleset: $NFT_FILE"
local rline=""
rline=$(iptables -nL FORWARD --line-numbers 2>/dev/null | grep "$CHAIN" | awk '{print $1}' | head -n 1 || true) nft -f "$NFT_FILE"
if [[ -n "$rline" ]]; then log_info "Loaded nftables ruleset from $NFT_FILE"
iptables -D FORWARD "$rline" 2>/dev/null || break
# === POPULATE SETS ===
log_info "Populating whitelist sets..."
local v4_count=0
local v6_count=0
while IFS= read -r line; do
[[ -z "$line" ]] && continue
local client_ip target
client_ip=$(echo "$line" | awk '{print $1}')
target=$(echo "$line" | awk '{print $2}')
[[ -z "$client_ip" || -z "$target" ]] && continue
if [[ "$target" == *":"* ]]; then
nft add element "$NFT_TABLE_FULL" "$NFT_SET_V6" { "$client_ip" . "$target" } 2>/dev/null || \
log_warn "Failed to add ${client_ip} . ${target} to set $NFT_SET_V6"
(( v6_count++ )) || true
else else
break nft add element "$NFT_TABLE_FULL" "$NFT_SET_V4" { "$client_ip" . "$target" } 2>/dev/null || \
log_warn "Failed to add ${client_ip} . ${target} to set $NFT_SET_V4"
(( v4_count++ )) || true
fi fi
done done < <(jq -r '
# Backup existing chain before flushing
if iptables -L "$CHAIN" -n &>/dev/null; then
iptables -N "$CHAIN_BACKUP" 2>/dev/null || iptables -F "$CHAIN_BACKUP"
iptables-save -c 2>/dev/null | grep "^-A $CHAIN" | \
sed "s/-A $CHAIN/-A $CHAIN_BACKUP/" | \
iptables-restore -c 2>/dev/null || true
log_info "Backed up existing chain to $CHAIN_BACKUP"
fi
iptables -F "$CHAIN" 2>/dev/null || true
iptables -X "$CHAIN" 2>/dev/null || true
# === CREATE FRESH CHAIN ===
iptables -N "$CHAIN"
if ! iptables -C FORWARD -i "$WG_IF" -j "$CHAIN" 2>/dev/null; then
iptables -I FORWARD 1 -i "$WG_IF" -j "$CHAIN"
fi
if ! iptables -C FORWARD -o "$WG_IF" -j "$CHAIN" 2>/dev/null; then
iptables -I FORWARD 2 -o "$WG_IF" -j "$CHAIN"
fi
log_info "Chain $CHAIN created and linked to FORWARD (In/Out)"
# === POPULATE IPSET (hash:net,net for source->target mapping) ===
local use_ipset=false
if has_ipset; then
use_ipset=true
log_info "Populating ipsets..."
ensure_ipset "$IPSET_V4" "inet"
flush_ipset "$IPSET_V4"
# Check if we need IPv6 ipset
local use_ipv6=false
if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then
use_ipv6=true
ensure_ipset "$IPSET_V6" "inet6"
flush_ipset "$IPSET_V6"
fi
# Read all access entries and populate ipset (client_ip,target)
jq -r '
.clients // {} | to_entries[] |
select(.value.access != null and (.value.access | length > 0)) |
.key as $ip |
.value.access[] |
"\($ip) \(.)"
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do
[[ -z "$client_ip" || -z "$target" ]] && continue
if [[ "$target" == *":"* ]]; then
if [[ "$use_ipv6" == true ]]; then
ipset add "$IPSET_V6" "${client_ip},${target}" 2>/dev/null || \
log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V6"
fi
else
ipset add "$IPSET_V4" "${client_ip},${target}" 2>/dev/null || \
log_warn "Failed to add ${client_ip},${target} to ipset $IPSET_V4"
fi
done
local v4_count v6_count
v4_count=$(ipset list "$IPSET_V4" 2>/dev/null | grep -c '^[0-9]' || echo 0)
v6_count=$(ipset list "$IPSET_V6" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0)
log_info "ipset $IPSET_V4: $v4_count entries, $IPSET_V6: $v6_count entries"
else
log_warn "ipset not installed, falling back to per-rule iptables whitelist"
local use_ipv6=false
if [[ -n "$WG_SUBNET_V6" ]] && command -v ip6tables &>/dev/null; then
use_ipv6=true
fi
fi
# === RULE 1: ESTABLISHED,RELATED — allow return traffic ===
iptables -A "$CHAIN" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# === RULE 2: WHITELIST (per-client source) ===
if [[ "$use_ipset" == true ]]; then
iptables -A "$CHAIN" -m set --match-set "$IPSET_V4" src,dst -j ACCEPT
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -m set --match-set "$IPSET_V6" src,dst -j ACCEPT 2>/dev/null || true
fi
else
jq -r '
.clients // {} | to_entries[] |
select(.value.access != null and (.value.access | length > 0)) |
.key as $ip |
.value.access[] |
"\($ip) \(.)"
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip target; do
[[ -z "$client_ip" || -z "$target" ]] && continue
if [[ "$client_ip" == *":"* ]]; then
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT 2>/dev/null || true
fi
else
iptables -A "$CHAIN" -s "$client_ip" -d "$target" -j ACCEPT
fi
done
fi
# === RULE 3: ISOLATION — drop NEW connections between WG clients ===
if [[ -n "$WG_SUBNET" ]]; then
iptables -A "$CHAIN" \
-s "$WG_SUBNET" \
-d "$WG_SUBNET" \
-m conntrack --ctstate NEW \
-j DROP
log_info "Client isolation enabled for $WG_SUBNET"
fi
if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then
ip6tables -A "$CHAIN" \
-s "$WG_SUBNET_V6" \
-d "$WG_SUBNET_V6" \
-m conntrack --ctstate NEW \
-j DROP 2>/dev/null || true
log_info "Client isolation enabled for IPv6 $WG_SUBNET_V6"
fi
# === RULE 4: BLOCK LAN — drop from WG subnet to private LAN ===
if [[ -n "$WG_SUBNET" && -n "$LAN_SUBNETS" ]]; then
echo "$LAN_SUBNETS" | while read -r subnet; do
[[ -z "$subnet" ]] && continue
# Skip if LAN subnet exactly matches WG subnet (handled by Rule 3)
[[ "$subnet" == "$WG_SUBNET" ]] && continue
iptables -A "$CHAIN" -s "$WG_SUBNET" -d "$subnet" -j DROP
log_info "Block: $WG_SUBNET$subnet"
done
fi
# IPv6 LAN block (link-local and ULA)
if [[ "$use_ipv6" == true && -n "$WG_SUBNET_V6" ]]; then
# Block to link-local (fe80::/10)
ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fe80::/10" -j DROP 2>/dev/null || true
# Block to ULA (fc00::/7)
ip6tables -A "$CHAIN" -s "$WG_SUBNET_V6" -d "fc00::/7" -j DROP 2>/dev/null || true
log_info "IPv6 LAN block applied (link-local + ULA)"
fi
# === RULE 5: INTERNET ACCESS (#Internet = true) ===
jq -r '
.clients // {} | to_entries[] | .clients // {} | to_entries[] |
select(.value.internet == "true") | select(.value.access != null and (.value.access | length > 0)) |
"\(.key)" .key as $ip |
' "$POLICY_FILE" 2>/dev/null | while read -r client_ip; do .value.access[] |
[[ -z "$client_ip" ]] && continue "\($ip) \(.)"
' "$POLICY_FILE" 2>/dev/null)
if [[ "$client_ip" == *":"* ]]; then log_info "Set $NFT_SET_V4: $v4_count entries, $NFT_SET_V6: $v6_count entries"
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -s "$client_ip" -j ACCEPT 2>/dev/null || true
fi
else
iptables -A "$CHAIN" -s "$client_ip" -j ACCEPT
fi
done
# === RULE 6: LOGGING (rate-limited) — BEFORE final DROP === # === CLEANUP BACKUP (success path) ===
iptables -A "$CHAIN" \ rm -f "$NFT_BACKUP" 2>/dev/null || true
-m limit --limit "$LOG_RATE" \
-j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" \
-m limit --limit "$LOG_RATE" \
-j LOG --log-prefix "${LOG_PREFIX}: " --log-level 4 2>/dev/null || true
fi
# === RULE 7: DEFAULT DROP (internet block by default) ===
iptables -A "$CHAIN" -j DROP
if [[ "$use_ipv6" == true ]]; then
ip6tables -A "$CHAIN" -j DROP 2>/dev/null || true
fi
# === CLEANUP BACKUP CHAIN (no rollback needed anymore) ===
iptables -F "$CHAIN_BACKUP" 2>/dev/null || true
iptables -X "$CHAIN_BACKUP" 2>/dev/null || true
# Disable ERR trap (success path) # Disable ERR trap (success path)
trap - ERR trap - ERR
# === VERIFY === # === VERIFY ===
local rule_count local rule_count
rule_count=$(iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l) rule_count=$(nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '^\s*' || echo 0)
log_info "Policy applied. Chain: $CHAIN, Rules: $rule_count" log_info "Policy applied. Table: $NFT_TABLE, Rules: $rule_count"
echo "[OK] iptables policy applied. Chain: $CHAIN" echo "[OK] nftables policy applied. Table: $NFT_TABLE"
} }
main "$@" main "$@"
+35 -78
View File
@@ -8,16 +8,16 @@ set -euo pipefail
# CONFIGURATION # CONFIGURATION
# ============================================================ # ============================================================
readonly WG_IF="${WG_IF:-wg0}" readonly WG_IF="${WG_IF:-wg0}"
readonly CHAIN="WG_POLICY" readonly NFT_TABLE="wg_policy"
readonly CHAIN_BACKUP="WG_POLICY_BAK" readonly NFT_TABLE_FULL="inet wg_policy"
readonly NFT_SET_V4="wg_allowed_v4"
readonly NFT_SET_V6="wg_allowed_v6"
readonly POLICY_FILE="/etc/wireguard/policy.json" readonly POLICY_FILE="/etc/wireguard/policy.json"
readonly WG_CONF="/etc/wireguard/wg0.conf" readonly WG_CONF="/etc/wireguard/wg0.conf"
readonly LOCK_FILE="/var/lock/wg-policy.lock" readonly LOCK_FILE="/var/lock/wg-policy.lock"
readonly BACKUP_DIR="/etc/wireguard/backups" readonly BACKUP_DIR="/etc/wireguard/backups"
readonly LOG_PREFIX="WG_DROP" readonly LOG_PREFIX="WG_DROP"
readonly LOG_RATE="10/min" readonly LOG_RATE="10/minute"
readonly IPSET_V4="wg_allowed_v4"
readonly IPSET_V6="wg_allowed_v6"
readonly MAX_RETRY=3 readonly MAX_RETRY=3
readonly RETRY_DELAY=2 readonly RETRY_DELAY=2
readonly DEBOUNCE_SEC=2 readonly DEBOUNCE_SEC=2
@@ -33,17 +33,14 @@ log_error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] [ERROR] $*" >&2; }
# VALIDATION # VALIDATION
# ============================================================ # ============================================================
# Validate IPv4 address (strict: 0-255 per octet, no leading zeros)
validate_ipv4() { validate_ipv4() {
local ip="$1" local ip="$1"
# Match basic pattern
if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then if [[ ! "$ip" =~ ^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})$ ]]; then
return 1 return 1
fi fi
local IFS='.' local IFS='.'
read -ra octets <<< "$ip" read -ra octets <<< "$ip"
for octet in "${octets[@]}"; do for octet in "${octets[@]}"; do
# Reject leading zeros (except "0" itself)
if [[ "$octet" =~ ^0[0-9] ]]; then if [[ "$octet" =~ ^0[0-9] ]]; then
return 1 return 1
fi fi
@@ -54,7 +51,6 @@ validate_ipv4() {
return 0 return 0
} }
# Validate IPv4 CIDR (e.g., 192.168.1.0/24)
validate_ipv4_cidr() { validate_ipv4_cidr() {
local cidr="$1" local cidr="$1"
local ip prefix local ip prefix
@@ -63,7 +59,6 @@ validate_ipv4_cidr() {
ip="${cidr%%/*}" ip="${cidr%%/*}"
prefix="${cidr##*/}" prefix="${cidr##*/}"
else else
# Single IP treated as /32
ip="$cidr" ip="$cidr"
prefix="32" prefix="32"
fi fi
@@ -78,10 +73,8 @@ validate_ipv4_cidr() {
return 0 return 0
} }
# Validate IPv6 address (basic check)
validate_ipv6() { validate_ipv6() {
local ip="$1" local ip="$1"
# Basic IPv6 pattern — covers full, compressed, and mixed notation
if [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]] || \ if [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$ ]] || \
[[ "$ip" =~ ^::([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$ ]] || \ [[ "$ip" =~ ^::([0-9a-fA-F]{0,4}:){0,5}[0-9a-fA-F]{0,4}$ ]] || \
[[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){1,7}:$ ]] || \ [[ "$ip" =~ ^([0-9a-fA-F]{0,4}:){1,7}:$ ]] || \
@@ -92,7 +85,6 @@ validate_ipv6() {
return 1 return 1
} }
# Validate IPv6 CIDR
validate_ipv6_cidr() { validate_ipv6_cidr() {
local cidr="$1" local cidr="$1"
local ip prefix local ip prefix
@@ -115,7 +107,6 @@ validate_ipv6_cidr() {
return 0 return 0
} }
# Generic CIDR validator — dispatches to v4 or v6
validate_cidr() { validate_cidr() {
local cidr="$1" local cidr="$1"
if [[ "$cidr" == *":"* ]]; then if [[ "$cidr" == *":"* ]]; then
@@ -126,36 +117,11 @@ validate_cidr() {
} }
# ============================================================ # ============================================================
# IPSET MANAGEMENT # NFTABLES HELPERS
# ============================================================ # ============================================================
has_ipset() { has_nft() {
command -v ipset &>/dev/null command -v nft &>/dev/null
}
ensure_ipset() {
local name="$1" family="$2"
has_ipset || return 0
if ! ipset list "$name" &>/dev/null; then
ipset create "$name" hash:net,net family "$family" hashsize 1024 maxelem 65536 timeout 0
log_info "Created ipset: $name (family=$family)"
fi
}
flush_ipset() {
local name="$1"
has_ipset || return 0
if ipset list "$name" &>/dev/null; then
ipset flush "$name"
fi
}
destroy_ipset() {
local name="$1"
has_ipset || return 0
if ipset list "$name" &>/dev/null; then
ipset destroy "$name"
fi
} }
# ============================================================ # ============================================================
@@ -176,7 +142,7 @@ retry() {
log_warn "Attempt $attempt/$max_attempts failed (exit=$exit_code), retrying in ${delay}s..." log_warn "Attempt $attempt/$max_attempts failed (exit=$exit_code), retrying in ${delay}s..."
sleep "$delay" sleep "$delay"
(( attempt++ )) (( attempt++ ))
(( delay *= 2 )) # exponential backoff (( delay *= 2 ))
done done
log_error "All $max_attempts attempts failed for: $*" log_error "All $max_attempts attempts failed for: $*"
@@ -198,7 +164,6 @@ acquire_lock() {
} }
release_lock() { release_lock() {
# Lock released automatically when fd closes, but we clean up file
rm -f "$LOCK_FILE" 2>/dev/null || true rm -f "$LOCK_FILE" 2>/dev/null || true
} }
@@ -216,7 +181,6 @@ backup_policy() {
log_info "Backup created: ${BACKUP_DIR}/policy_${timestamp}.json" log_info "Backup created: ${BACKUP_DIR}/policy_${timestamp}.json"
fi fi
# Keep only last 50 backups
local count local count
count=$(find "$BACKUP_DIR" -name 'policy_*.json' -type f | wc -l) count=$(find "$BACKUP_DIR" -name 'policy_*.json' -type f | wc -l)
if (( count > 50 )); then if (( count > 50 )); then
@@ -229,31 +193,24 @@ backup_policy() {
fi fi
} }
backup_iptables() { backup_nftables() {
mkdir -p "$BACKUP_DIR" mkdir -p "$BACKUP_DIR"
local timestamp local timestamp
timestamp="$(date '+%Y%m%d_%H%M%S')" timestamp="$(date '+%Y%m%d_%H%M%S')"
if iptables-save > "${BACKUP_DIR}/iptables_${timestamp}.rules" 2>/dev/null; then if nft list ruleset > "${BACKUP_DIR}/nftables_${timestamp}.rules" 2>/dev/null; then
log_info "iptables backup: ${BACKUP_DIR}/iptables_${timestamp}.rules" log_info "nftables backup: ${BACKUP_DIR}/nftables_${timestamp}.rules"
fi fi
if command -v ip6tables-save &>/dev/null; then local count
ip6tables-save > "${BACKUP_DIR}/ip6tables_${timestamp}.rules" 2>/dev/null || true count=$(find "$BACKUP_DIR" -name 'nftables_*.rules' -type f | wc -l)
if (( count > 20 )); then
find "$BACKUP_DIR" -name 'nftables_*.rules' -type f -printf '%T@ %p\n' \
| sort -n \
| head -n $(( count - 20 )) \
| awk '{print $2}' \
| xargs rm -f
fi fi
# Keep only last 20 iptables backups
for prefix in iptables ip6tables; do
local count
count=$(find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f | wc -l)
if (( count > 20 )); then
find "$BACKUP_DIR" -name "${prefix}_*.rules" -type f -printf '%T@ %p\n' \
| sort -n \
| head -n $(( count - 20 )) \
| awk '{print $2}' \
| xargs rm -f
fi
done
} }
# ============================================================ # ============================================================
@@ -314,32 +271,32 @@ health_check() {
status=1 status=1
fi fi
# 3. Check chain exists # 3. Check nftables table exists
if iptables -L "$CHAIN" -n &>/dev/null; then if nft list table "$NFT_TABLE_FULL" &>/dev/null; then
local rule_count local rule_count
rule_count=$(iptables -L "$CHAIN" -n 2>/dev/null | tail -n +3 | wc -l) rule_count=$(nft list chain "$NFT_TABLE_FULL" forward 2>/dev/null | grep -c '^\s*' || echo 0)
report+="[OK] Chain $CHAIN active ($rule_count rules)\n" report+="[OK] Table $NFT_TABLE active ($rule_count rules)\n"
else else
report+="[WARN] Chain $CHAIN not found\n" report+="[WARN] Table $NFT_TABLE not found\n"
status=1 status=1
fi fi
# 4. Check FORWARD reference # 4. Check forward chain exists in table
if iptables -L FORWARD -n 2>/dev/null | grep -q "$CHAIN"; then if nft list chain "$NFT_TABLE_FULL" forward &>/dev/null; then
report+="[OK] FORWARD chain references $CHAIN\n" report+="[OK] Forward chain exists in $NFT_TABLE\n"
else else
report+="[WARN] FORWARD chain has no reference to $CHAIN\n" report+="[WARN] Forward chain not found in $NFT_TABLE\n"
status=1 status=1
fi fi
# 5. Check ipset # 5. Check nftables sets
for set_name in "$IPSET_V4" "$IPSET_V6"; do for set_name in "$NFT_SET_V4" "$NFT_SET_V6"; do
if ipset list "$set_name" &>/dev/null; then if nft list set "$NFT_TABLE_FULL" "$set_name" &>/dev/null; then
local entry_count local entry_count
entry_count=$(ipset list "$set_name" 2>/dev/null | grep -c '^[0-9a-f:]' || echo 0) entry_count=$(nft list set "$NFT_TABLE_FULL" "$set_name" 2>/dev/null | grep -c '^\s*' || echo 0)
report+="[OK] ipset $set_name active ($entry_count entries)\n" report+="[OK] Set $set_name active ($entry_count entries)\n"
else else
report+="[INFO] ipset $set_name not created (may not be needed)\n" report+="[INFO] Set $set_name not created (may not be needed)\n"
fi fi
done done