diff --git a/docker/README.md b/docker/README.md index b6966712..46ea4ba5 100644 --- a/docker/README.md +++ b/docker/README.md @@ -213,6 +213,75 @@ 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. diff --git a/src/dashboard.py b/src/dashboard.py index e4d4eefb..e04afadc 100644 --- a/src/dashboard.py +++ b/src/dashboard.py @@ -2,6 +2,8 @@ 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 @@ -68,6 +70,21 @@ 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 ''' @@ -1204,52 +1221,75 @@ def API_getDashboardTheme(): def API_getDashboardVersion(): return ResponseObject(data=DashboardConfig.GetConfig("Server", "version")[1]) -@app.post(f'{APP_PREFIX}/api/savePeerScheduleJob') +@app.post(f'{APP_PREFIX}/api/PeerScheduleJob') +@require_fields('Configuration', 'Peer', 'Field', 'Operator', 'Value', 'Action') def API_savePeerScheduleJob(): data = request.json - 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") - f, fp = configuration.searchPeer(job['Peer']) - if not f: - return ResponseObject(False, "Peer does not exist") - - - 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) -@app.post(f'{APP_PREFIX}/api/deletePeerScheduleJob') + 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) + + jobID = data.get('JobID', str(uuid4())) + if len(AllPeerJobs.searchJobById(jobID)) > 0: + return ResponseObject(False, "Job already exists", status_code=409) + + 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') def API_deletePeerScheduleJob(): data = request.json - 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']) + + configuration = WireguardConfigurations.get(data['Configuration']) if configuration is None: - return ResponseObject(False, "Configuration does not exist") - # f, fp = configuration.searchPeer(job['Peer']) - # if not f: - # return ResponseObject(False, "Peer does not exist") + return ResponseObject(False, "Configuration does not exist", status_code=404) - 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) + 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) -@app.get(f'{APP_PREFIX}/api/getPeerScheduleJobLogs/') +@app.get(f'{APP_PREFIX}/api/PeerScheduleJobLogs/') def API_getPeerScheduleJobLogs(configName): if configName not in WireguardConfigurations.keys(): return ResponseObject(False, "Configuration does not exist") diff --git a/src/modules/AmneziaPeer.py b/src/modules/AmneziaPeer.py index 509f4305..392f009d 100644 --- a/src/modules/AmneziaPeer.py +++ b/src/modules/AmneziaPeer.py @@ -116,5 +116,5 @@ class AmneziaPeer(Peer): self.configuration.getPeers() return True, None except subprocess.CalledProcessError as exc: - current_app.logger.error(f"Subprocess call failed:\n{exc.output.decode("UTF-8")}") + current_app.logger.error(f"Subprocess call failed:\n{exc.output.decode('UTF-8')}") return False, "Internal server error" diff --git a/src/modules/Peer.py b/src/modules/Peer.py index 93ee3122..1fc4e65d 100644 --- a/src/modules/Peer.py +++ b/src/modules/Peer.py @@ -151,7 +151,7 @@ class Peer: ) return True, None except subprocess.CalledProcessError as exc: - current_app.logger.error(f"Subprocess call failed:\n{exc.output.decode("UTF-8")}") + current_app.logger.error(f"Subprocess call failed:\n{exc.output.decode('UTF-8')}") return False, "Internal server error" def downloadPeer(self) -> dict[str, str]: diff --git a/src/static/app/src/components/configurationComponents/peerJobsLogsModal.vue b/src/static/app/src/components/configurationComponents/peerJobsLogsModal.vue index 4f59c1ad..c9f82a68 100644 --- a/src/static/app/src/components/configurationComponents/peerJobsLogsModal.vue +++ b/src/static/app/src/components/configurationComponents/peerJobsLogsModal.vue @@ -26,7 +26,7 @@ export default { methods: { async fetchLog(){ this.dataLoading = true; - await fetchGet(`/api/getPeerScheduleJobLogs/${this.configurationInfo.Name}`, {}, (res) => { + await fetchGet(`/api/PeerScheduleJobLogs/${this.configurationInfo.Name}`, {}, (res) => { this.data = res.data; this.logFetchTime = dayjs().format("YYYY-MM-DD HH:mm:ss") this.dataLoading = false; diff --git a/src/static/app/src/components/configurationComponents/peerScheduleJobsComponents/schedulePeerJob.vue b/src/static/app/src/components/configurationComponents/peerScheduleJobsComponents/schedulePeerJob.vue index 291043cd..e0d577e6 100644 --- a/src/static/app/src/components/configurationComponents/peerScheduleJobsComponents/schedulePeerJob.vue +++ b/src/static/app/src/components/configurationComponents/peerScheduleJobsComponents/schedulePeerJob.vue @@ -2,7 +2,7 @@ import ScheduleDropdown from "@/components/configurationComponents/peerScheduleJobsComponents/scheduleDropdown.vue"; import {ref} from "vue"; import {DashboardConfigurationStore} from "@/stores/DashboardConfigurationStore.js"; -import {fetchPost} from "@/utilities/fetch.js"; +import {fetchPost, fetchPut, fetchDelete} from "@/utilities/fetch.js"; import { VueDatePicker } from "@vuepic/vue-datepicker"; import dayjs from "dayjs"; import LocaleText from "@/components/text/localeText.vue"; @@ -46,9 +46,8 @@ export default { methods: { save(){ if (this.job.Field && this.job.Operator && this.job.Action && this.job.Value){ - fetchPost(`/api/savePeerScheduleJob`, { - Job: this.job - }, (res) => { + const fn = this.newJob ? fetchPost : fetchPut; + fn(`/api/PeerScheduleJob`, this.job, (res) => { if (res.status){ this.edit = false; this.store.newMessage("Server", "Peer job saved", "success") @@ -84,9 +83,7 @@ export default { }, delete(){ if(this.job.CreationDate){ - fetchPost(`/api/deletePeerScheduleJob`, { - Job: this.job - }, (res) => { + fetchDelete(`/api/PeerScheduleJob`, this.job, (res) => { if (!res.status){ this.store.newMessage("Server", res.message, "danger") this.$emit('delete') diff --git a/src/static/app/src/utilities/fetch.js b/src/static/app/src/utilities/fetch.js index 51f4e947..ce10fd64 100644 --- a/src/static/app/src/utilities/fetch.js +++ b/src/static/app/src/utilities/fetch.js @@ -79,4 +79,50 @@ export const fetchPost = async (url, body, callback) => { console.log("Error:", x) 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'}) + }) } \ No newline at end of file diff --git a/src/wgd.sh b/src/wgd.sh index 29dc60db..9736d7cc 100755 --- a/src/wgd.sh +++ b/src/wgd.sh @@ -247,27 +247,19 @@ _checkWireguard(){ _checkPythonVersion(){ - version_pass=$($pythonExecutable -c 'import sys; print("1") if (sys.version_info.major == 3 and sys.version_info.minor >= 10) else print("0");') + version_pass=$($pythonExecutable -c 'import sys; print("1") if (sys.version_info.major == 3 and sys.version_info.minor >= 12) else print("0");') version=$($pythonExecutable --version) if [ $version_pass == "1" ] then printf "[WGDashboard] %s Found compatible version of Python. Will be using %s to install WGDashboard.\n" "$heavy_checkmark" "$($pythonExecutable --version)" return; - elif python3.10 --version > /dev/null 2>&1 - then - printf "[WGDashboard] %s Found Python 3.10. Will be using [python3.10] to install WGDashboard.\n" "$heavy_checkmark" - pythonExecutable="python3.10" - elif python3.11 --version > /dev/null 2>&1 - then - printf "[WGDashboard] %s Found Python 3.11. Will be using [python3.11] to install WGDashboard.\n" "$heavy_checkmark" - pythonExecutable="python3.11" elif python3.12 --version > /dev/null 2>&1 then printf "[WGDashboard] %s Found Python 3.12. Will be using [python3.12] to install WGDashboard.\n" "$heavy_checkmark" pythonExecutable="python3.12" else printf "[WGDashboard] %s Could not find a compatible version of Python. Current Python is %s.\n" "$heavy_crossmark" "$version" - printf "[WGDashboard] WGDashboard required Python 3.10, 3.11 or 3.12. Halting install now.\n" + printf "[WGDashboard] WGDashboard required Python 3.12 or above. Halting install now.\n" kill $TOP_PID fi }