Compare commits

..

3 Commits

Author SHA1 Message Date
Donald Zou
42f9460369 Update wgd.sh 2026-04-10 16:18:12 +08:00
Donald Zou
71f4449741 Fixed quotation marks 2026-04-10 15:45:26 +08:00
Donald Zou
081c63cd43 Merge pull request #1197 from WGDashboard/development
v4.3.3 Merge
2026-04-10 14:50:10 +08:00
17 changed files with 2499 additions and 1955 deletions

View File

@@ -213,75 +213,6 @@ ENTRYPOINT ["/bin/bash", "/entrypoint.sh"]
- Access the web interface via `http://your-ip:10086` (or whichever port you specified in the compose).
- The first time run will auto-generate WireGuard keys and configs (configs are generated from the template).
## 🧑‍💻 Local Development with Docker
You can develop against WGDashboard locally by mounting the `src/` directory into the container. This lets you edit Python and frontend code on your host and see changes reflected immediately (with a service restart for Python).
Create a `docker/compose-local.yaml` alongside the existing `compose.yaml`:
```yaml
services:
wgdashboard:
image: ghcr.io/wgdashboard/wgdashboard:latest
restart: unless-stopped
container_name: wgdashboard
ports:
- 10086:10086/tcp
- 51820:51820/udp
volumes:
- aconf:/etc/amnezia/amneziawg
- conf:/etc/wireguard
- data:/data
# Mount local src for live editing
- ../src:/opt/wgdashboard/src
# Keep venv in a named volume so it isn't overwritten by the mount
- venv:/opt/wgdashboard/src/venv
cap_add:
- NET_ADMIN
volumes:
aconf:
conf:
data:
venv:
```
The key additions compared to the production compose file:
- `../src:/opt/wgdashboard/src` — mounts your local `src/` directory into the container so code changes are reflected without rebuilding the image.
- `venv:/opt/wgdashboard/src/venv` — keeps the Python virtual environment in a named Docker volume. Without this, the host mount would overwrite the venv created during image build.
To start the development container:
```bash
cd docker
docker compose -f compose-local.yaml up -d
```
After editing Python files (e.g. `src/dashboard.py`), restart the container to pick up changes:
```bash
docker restart wgdashboard
```
For frontend changes, install dependencies and rebuild the Vue app on your host:
```bash
cd src/static/app
npm install
npm run build
```
Then restart the container so it serves the updated dist files:
```bash
docker restart wgdashboard
```
---
## Closing remarks:
For feedback please submit an issue to the repository. Or message dselen@nerthus.nl.

View File

@@ -100,7 +100,7 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration],
date = datetime.datetime.now(tz=datetime.timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')
emailSender = EmailSender(dashboardConfig)
if not emailSender.is_ready():
if not emailSender.ready():
return ResponseObject(False, "We can't send you an email due to your Administrator has not setup email service. Please contact your administrator.")
data = request.get_json()

View File

@@ -2,8 +2,6 @@ import logging
import random, shutil, sqlite3, configparser, hashlib, ipaddress, json, os, secrets, subprocess
import time, re, uuid, bcrypt, psutil, pyotp, threading
import traceback
from functools import wraps
from urllib.parse import unquote
from uuid import uuid4
from zipfile import ZipFile
from datetime import datetime, timedelta
@@ -70,21 +68,6 @@ def ResponseObject(status=True, message=None, data=None, status_code = 200) -> F
response.content_type = "application/json"
return response
def require_fields(*fields):
"""Decorator that validates required fields in request.json."""
def decorator(f):
@wraps(f)
def decorated(*args, **kwargs):
data = request.json
if data is None:
return ResponseObject(False, "Request body must be JSON", status_code=400)
missing = [field for field in fields if field not in data]
if missing:
return ResponseObject(False, f"Missing required fields: {', '.join(missing)}", status_code=400)
return f(*args, **kwargs)
return decorated
return decorator
'''
Flask App
'''
@@ -1221,75 +1204,52 @@ def API_getDashboardTheme():
def API_getDashboardVersion():
return ResponseObject(data=DashboardConfig.GetConfig("Server", "version")[1])
@app.post(f'{APP_PREFIX}/api/PeerScheduleJob')
@require_fields('Configuration', 'Peer', 'Field', 'Operator', 'Value', 'Action')
@app.post(f'{APP_PREFIX}/api/savePeerScheduleJob')
def API_savePeerScheduleJob():
data = request.json
configuration = WireguardConfigurations.get(data['Configuration'])
if "Job" not in data.keys():
return ResponseObject(False, "Please specify job")
job: dict = data['Job']
if "Peer" not in job.keys() or "Configuration" not in job.keys():
return ResponseObject(False, "Please specify peer and configuration")
configuration = WireguardConfigurations.get(job['Configuration'])
if configuration is None:
return ResponseObject(False, "Configuration does not exist", status_code=404)
return ResponseObject(False, "Configuration does not exist")
f, fp = configuration.searchPeer(job['Peer'])
if not f:
return ResponseObject(False, "Peer does not exist")
peerKey = unquote(data['Peer'])
found, _ = configuration.searchPeer(peerKey)
if not found:
return ResponseObject(False, "Peer does not exist", status_code=404)
jobID = data.get('JobID', str(uuid4()))
if len(AllPeerJobs.searchJobById(jobID)) > 0:
return ResponseObject(False, "Job already exists", status_code=409)
s, p = AllPeerJobs.saveJob(PeerJob(
job['JobID'], job['Configuration'], job['Peer'], job['Field'], job['Operator'], job['Value'],
job['CreationDate'], job['ExpireDate'], job['Action']))
if s:
return ResponseObject(s, data=p)
return ResponseObject(s, message=p)
success, result = AllPeerJobs.saveJob(PeerJob(
jobID, data['Configuration'], peerKey, data['Field'], data['Operator'], data['Value'],
datetime.now(), data.get('ExpireDate'), data['Action']))
if success:
return ResponseObject(success, data=result)
return ResponseObject(success, message=result)
@app.put(f'{APP_PREFIX}/api/PeerScheduleJob')
@require_fields('JobID', 'Configuration', 'Peer', 'Field', 'Operator', 'Value', 'Action')
def API_updatePeerScheduleJob():
data = request.json
configuration = WireguardConfigurations.get(data['Configuration'])
if configuration is None:
return ResponseObject(False, "Configuration does not exist", status_code=404)
peerKey = unquote(data['Peer'])
found, _ = configuration.searchPeer(peerKey)
if not found:
return ResponseObject(False, "Peer does not exist", status_code=404)
existing = AllPeerJobs.searchJobById(data['JobID'])
if len(existing) == 0:
return ResponseObject(False, "Job does not exist", status_code=404)
success, result = AllPeerJobs.saveJob(PeerJob(
data['JobID'], data['Configuration'], peerKey, data['Field'], data['Operator'], data['Value'],
datetime.now(), data.get('ExpireDate'), data['Action']))
if success:
return ResponseObject(success, data=result)
return ResponseObject(success, message=result)
@app.delete(f'{APP_PREFIX}/api/PeerScheduleJob')
@require_fields('JobID', 'Configuration', 'Peer')
@app.post(f'{APP_PREFIX}/api/deletePeerScheduleJob')
def API_deletePeerScheduleJob():
data = request.json
configuration = WireguardConfigurations.get(data['Configuration'])
if "Job" not in data.keys():
return ResponseObject(False, "Please specify job")
job: dict = data['Job']
if "Peer" not in job.keys() or "Configuration" not in job.keys():
return ResponseObject(False, "Please specify peer and configuration")
configuration = WireguardConfigurations.get(job['Configuration'])
if configuration is None:
return ResponseObject(False, "Configuration does not exist", status_code=404)
return ResponseObject(False, "Configuration does not exist")
# f, fp = configuration.searchPeer(job['Peer'])
# if not f:
# return ResponseObject(False, "Peer does not exist")
peerKey = unquote(data['Peer'])
success, result = AllPeerJobs.deleteJob(PeerJob(
data['JobID'], data['Configuration'], peerKey, data.get('Field', ''),
data.get('Operator', ''), data.get('Value', ''),
datetime.now(), data.get('ExpireDate'), data.get('Action', '')))
if success:
return ResponseObject(success, message="Job deleted successfully")
return ResponseObject(success, message=result)
s, p = AllPeerJobs.deleteJob(PeerJob(
job['JobID'], job['Configuration'], job['Peer'], job['Field'], job['Operator'], job['Value'],
job['CreationDate'], job['ExpireDate'], job['Action']))
if s:
return ResponseObject(s)
return ResponseObject(s, message=p)
@app.get(f'{APP_PREFIX}/api/PeerScheduleJobLogs/<configName>')
@app.get(f'{APP_PREFIX}/api/getPeerScheduleJobLogs/<configName>')
def API_getPeerScheduleJobLogs(configName):
if configName not in WireguardConfigurations.keys():
return ResponseObject(False, "Configuration does not exist")

View File

@@ -51,8 +51,7 @@ class DashboardConfig:
"peer_display_mode": "grid",
"remote_endpoint": GetRemoteEndpoint(),
"peer_MTU": "1420",
"peer_keep_alive": "21",
"peer_preshared_key_default": "false"
"peer_keep_alive": "21"
},
"Other": {
"welcome_session": "true"
@@ -147,10 +146,7 @@ class DashboardConfig:
if col_name not in existing_columns:
type_str = col_type().compile(dialect=self.engine.dialect)
current_app.logger.info(f"Adding missing column '{col_name}' to table '{table_name}'")
preparer = self.engine.dialect.identifier_preparer
quoted_table = preparer.quote_identifier(table_name)
quoted_column = preparer.quote_identifier(col_name)
conn.execute(db.text(f"ALTER TABLE {quoted_table} ADD COLUMN {quoted_column} {type_str}"))
conn.execute(db.text(f'ALTER TABLE "{table_name}" ADD COLUMN "{col_name}" {type_str}'))
def getConnectionString(self, database) -> str or None:
sqlitePath = os.path.join(DashboardConfig.ConfigurationPath, "db")

View File

@@ -8,10 +8,10 @@ icmplib==3.0.4
gunicorn==25.0.3
requests==2.32.5
tcconfig==0.30.1
sqlalchemy==2.0.49
sqlalchemy==2.0.46
sqlalchemy_utils==0.42.1
psycopg[binary]==3.3.3
PyMySQL==1.1.2
tzlocal==5.3.1
python-jose==3.5.0
pydantic==2.13.0
pydantic==2.12.5

File diff suppressed because it is too large Load Diff

View File

@@ -20,28 +20,28 @@
"animate.css": "^4.1.1",
"bootstrap": "^5.3.2",
"bootstrap-icons": "^1.11.3",
"cidr-tools": "^11.3.3",
"cidr-tools": "^11.3.2",
"css-color-converter": "^2.0.0",
"dayjs": "^1.11.19",
"electron-builder": "^26.7.0",
"fuse.js": "^7.3.0",
"fuse.js": "^7.0.0",
"i": "^0.3.7",
"is-cidr": "^6.0.3",
"npm": "^11.8.0",
"ol": "^10.8.0",
"ol": "^10.7.0",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"qrcode": "^1.5.3",
"qrcodejs": "^1.0.0",
"simple-code-editor": "^2.0.9",
"uuid": "^13.0.0",
"vue": "^3.5.32",
"vue": "^3.5.31",
"vue-chartjs": "^5.3.3",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"vite": "^8.0.8"
"vite": "^7.3.1"
},
"overrides": {
"tar": "^7.5.6"

View File

@@ -6,27 +6,18 @@ export default {
components: {LocaleText},
props: {
data: Object,
saving: Boolean,
defaultEnabled: Boolean
saving: Boolean
},
data(){
return{
enable: false
}
},
mounted() {
const hasKey = !!(this.data && this.data.preshared_key && this.data.preshared_key.length > 0)
if (hasKey || this.defaultEnabled){
this.enable = true
}
},
watch:{
enable(){
if (this.enable) {
if (!this.data.preshared_key){
this.data.preshared_key = window.wireguard.generateKeypair().presharedKey
}
} else {
if (this.enable){
this.data.preshared_key = window.wireguard.generateKeypair().presharedKey
}else {
this.data.preshared_key = ""
}
}

View File

@@ -32,7 +32,7 @@ const peerData = ref({
keepalive: parseInt(dashboardStore.Configuration.Peers.peer_keep_alive),
mtu: parseInt(dashboardStore.Configuration.Peers.peer_mtu),
preshared_key: "",
preshared_key_bulkAdd: Boolean(dashboardStore.Configuration.Peers.peer_preshared_key_default),
preshared_key_bulkAdd: false,
allowed_ips_validation: true,
})
const availableIp = ref([])
@@ -128,7 +128,7 @@ watch(() => {
<EndpointAllowedIps :saving="saving" :data="peerData"></EndpointAllowedIps>
<div class="row gy-3">
<div class="col-sm" v-if="!peerData.bulkAdd">
<PresharedKeyInput :saving="saving" :data="peerData" :bulk="peerData.bulkAdd" :defaultEnabled="Boolean(dashboardStore.Configuration.Peers.peer_preshared_key_default)"></PresharedKeyInput>
<PresharedKeyInput :saving="saving" :data="peerData" :bulk="peerData.bulkAdd"></PresharedKeyInput>
</div>
<div class="col-sm">

View File

@@ -36,7 +36,7 @@ export default {
keepalive: parseInt(this.dashboardStore.Configuration.Peers.peer_keep_alive),
mtu: parseInt(this.dashboardStore.Configuration.Peers.peer_mtu),
preshared_key: "",
preshared_key_bulkAdd: Boolean(this.dashboardStore.Configuration.Peers.peer_preshared_key_default),
preshared_key_bulkAdd: false,
},
availableIp: undefined,
availableIpSearchString: "",
@@ -133,7 +133,7 @@ export default {
<hr class="mb-0 mt-2">
<div class="row gy-3">
<div class="col-sm" v-if="!this.data.bulkAdd">
<PresharedKeyInput :saving="saving" :data="data" :bulk="this.data.bulkAdd" :defaultEnabled="Boolean(this.dashboardStore.Configuration.Peers.peer_preshared_key_default)"></PresharedKeyInput>
<PresharedKeyInput :saving="saving" :data="data" :bulk="this.data.bulkAdd"></PresharedKeyInput>
</div>
<div class="col-sm">

View File

@@ -26,7 +26,7 @@ export default {
methods: {
async fetchLog(){
this.dataLoading = true;
await fetchGet(`/api/PeerScheduleJobLogs/${this.configurationInfo.Name}`, {}, (res) => {
await fetchGet(`/api/getPeerScheduleJobLogs/${this.configurationInfo.Name}`, {}, (res) => {
this.data = res.data;
this.logFetchTime = dayjs().format("YYYY-MM-DD HH:mm:ss")
this.dataLoading = false;

View File

@@ -2,7 +2,7 @@
import ScheduleDropdown from "@/components/configurationComponents/peerScheduleJobsComponents/scheduleDropdown.vue";
import {ref} from "vue";
import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js";
import {fetchPost, fetchPut, fetchDelete} from "@/utilities/fetch.js";
import {fetchPost} from "@/utilities/fetch.js";
import { VueDatePicker } from "@vuepic/vue-datepicker";
import dayjs from "dayjs";
import LocaleText from "@/components/text/localeText.vue";
@@ -46,8 +46,9 @@ export default {
methods: {
save(){
if (this.job.Field && this.job.Operator && this.job.Action && this.job.Value){
const fn = this.newJob ? fetchPost : fetchPut;
fn(`/api/PeerScheduleJob`, this.job, (res) => {
fetchPost(`/api/savePeerScheduleJob`, {
Job: this.job
}, (res) => {
if (res.status){
this.edit = false;
this.store.newMessage("Server", "Peer job saved", "success")
@@ -83,7 +84,9 @@ export default {
},
delete(){
if(this.job.CreationDate){
fetchDelete(`/api/PeerScheduleJob`, this.job, (res) => {
fetchPost(`/api/deletePeerScheduleJob`, {
Job: this.job
}, (res) => {
if (!res.status){
this.store.newMessage("Server", res.message, "danger")
this.$emit('delete')

View File

@@ -22,8 +22,6 @@ import PeersDefaultSettingsInput from "@/components/settingsComponent/peersDefau
targetData="peer_mtu" title="MTU"></PeersDefaultSettingsInput>
<PeersDefaultSettingsInput
targetData="peer_keep_alive" title="Persistent Keepalive"></PeersDefaultSettingsInput>
<PeersDefaultSettingsInput
targetData="peer_preshared_key_default" title="Pre-Shared Key Default"></PeersDefaultSettingsInput>
<PeersDefaultSettingsInput
targetData="remote_endpoint" title="Peer Remote Endpoint"
:warning="true" warningText="This will be changed globally, and will be apply to all peer's QR code and configuration file."

View File

@@ -31,11 +31,6 @@ export default {
mounted() {
this.value = this.store.Configuration.Peers[this.targetData];
},
computed: {
isBoolean(){
return typeof this.value === "boolean"
}
},
methods:{
async useValidation(){
if(this.changed){
@@ -72,14 +67,7 @@ export default {
<LocaleText :t="this.title"></LocaleText>
</small></strong>
</label>
<div v-if="isBoolean" class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch"
v-model="this.value"
:id="this.uuid"
@change="this.changed = true; useValidation()"
:disabled="this.updating">
</div>
<input v-else type="text" class="form-control"
<input type="text" class="form-control"
:class="{'is-invalid': showInvalidFeedback, 'is-valid': isValid}"
:id="this.uuid"
v-model="this.value"

View File

@@ -80,49 +80,3 @@ export const fetchPost = async (url, body, callback) => {
router.push({path: '/signin'})
})
}
export const fetchPut = async (url, body, callback) => {
await fetch(`${getUrl(url)}`, {
headers: getHeaders(),
method: "PUT",
body: JSON.stringify(body)
}).then((x) => {
const store = DashboardConfigurationStore();
if (!x.ok){
if (x.status !== 200){
if (x.status === 401){
store.newMessage("WGDashboard", "Sign in session ended, please sign in again", "warning")
}
throw new Error(x.statusText)
}
}else{
return x.json()
}
}).then(x => callback ? callback(x) : undefined).catch(x => {
console.log("Error:", x)
router.push({path: '/signin'})
})
}
export const fetchDelete = async (url, body, callback) => {
await fetch(`${getUrl(url)}`, {
headers: getHeaders(),
method: "DELETE",
body: JSON.stringify(body)
}).then((x) => {
const store = DashboardConfigurationStore();
if (!x.ok){
if (x.status !== 200){
if (x.status === 401){
store.newMessage("WGDashboard", "Sign in session ended, please sign in again", "warning")
}
throw new Error(x.statusText)
}
}else{
return x.json()
}
}).then(x => callback ? callback(x) : undefined).catch(x => {
console.log("Error:", x)
router.push({path: '/signin'})
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -13,16 +13,16 @@
"bootstrap": "^5.3.6",
"bootstrap-icons": "^1.13.1",
"dayjs": "^1.11.13",
"oidc-client-ts": "^3.5.0",
"oidc-client-ts": "^3.2.1",
"pinia": "^3.0.2",
"qrcode": "^1.5.4",
"uuid": "^13.0.0",
"vue": "^3.5.32",
"uuid": "^11.1.0",
"vue": "^3.5.13",
"vue-router": "^4.5.1"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.3",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1"
"vite": "^6.2.4",
"vite-plugin-vue-devtools": "^7.7.2"
}
}