diff --git a/NON_ROOT.md b/NON_ROOT.md new file mode 100644 index 00000000..352aa2e7 --- /dev/null +++ b/NON_ROOT.md @@ -0,0 +1,88 @@ +# Running WGDashboard as a non-root service user (advanced) + +WGDashboard can run as a non-root service user if you: +- grant controlled `sudo` access for `wg` and `wg-quick`, and +- allow read/write access to the WireGuard configuration files. + +This is an advanced setup. Make a backup before changing a live system. + +## 1) Create a service user and group + +``` +sudo groupadd --system wgdashboard +sudo groupadd --system wireguard +sudo useradd --system --home-dir /opt/wgdashboard --shell /sbin/nologin \ + --gid wgdashboard --groups wireguard wgdashboard +``` + +## 2) Set permissions + +``` +# WGDashboard app directory +sudo chown -R wgdashboard:wgdashboard /opt/wgdashboard/src +sudo chmod 0750 /opt/wgdashboard/src +sudo chmod 0750 /opt/wgdashboard/src/log /opt/wgdashboard/src/db +sudo chmod 0600 /opt/wgdashboard/src/wg-dashboard.ini + +# WireGuard config directory +sudo chgrp wireguard /etc/wireguard +sudo chmod 0750 /etc/wireguard +sudo chgrp wireguard /etc/wireguard/*.conf +sudo chmod 0660 /etc/wireguard/*.conf + +# Keep private keys locked to root +sudo chown root:root /etc/wireguard/*.key +sudo chmod 0600 /etc/wireguard/*.key +``` + +## 3) Sudoers allowlist (required) + +Create `/etc/sudoers.d/wgdashboard`: + +``` +Defaults:wgdashboard !requiretty +wgdashboard ALL=(root) NOPASSWD: /usr/sbin/wg, /usr/sbin/wg-quick +``` + +Validate: + +``` +sudo visudo -cf /etc/sudoers.d/wgdashboard +``` + +## 4) Systemd override + +Create `/etc/systemd/system/wg-dashboard.service.d/override.conf`: + +``` +[Service] +User=wgdashboard +Group=wgdashboard +SupplementaryGroups=wireguard +ExecStart= +ExecStart=/opt/wgdashboard/src/venv/bin/gunicorn --config /opt/wgdashboard/src/gunicorn.conf.py +ExecStop= +ExecStop=/bin/kill -TERM $MAINPID +ExecReload= +ExecReload=/bin/kill -HUP $MAINPID +``` + +Then reload and restart: + +``` +sudo systemctl daemon-reload +sudo systemctl restart wg-dashboard +``` + +## 5) Verify + +``` +systemctl status wg-dashboard +curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:10819/ +``` + +## Notes +- WGDashboard executes `wg` and `wg-quick`. This requires sudo when running as non-root. +- If you use AmneziaWG, ensure the corresponding binaries are available. +- If you use SELinux/AppArmor, add policy exceptions as needed. + diff --git a/src/modules/AmneziaWGPeer.py b/src/modules/AmneziaWGPeer.py index 17101b6b..e2f08527 100644 --- a/src/modules/AmneziaWGPeer.py +++ b/src/modules/AmneziaWGPeer.py @@ -5,7 +5,7 @@ import subprocess import uuid from .Peer import Peer -from .Utilities import ValidateIPAddressesWithRange, ValidateDNSAddress, GenerateWireguardPublicKey +from .Utilities import ValidateIPAddressesWithRange, ValidateDNSAddress, GenerateWireguardPublicKey, RunCommand class AmneziaWGPeer(Peer): @@ -58,16 +58,16 @@ class AmneziaWGPeer(Peer): with open(uid, "w+") as f: f.write(preshared_key) newAllowedIPs = allowed_ip.replace(" ", "") - updateAllowedIp = subprocess.check_output( - f"{self.configuration.Protocol} set {self.configuration.Name} peer {self.id} allowed-ips {newAllowedIPs} {f'preshared-key {uid}' if pskExist else 'preshared-key /dev/null'}", - shell=True, stderr=subprocess.STDOUT) + cmd = [self.configuration.Protocol, "set", self.configuration.Name, "peer", self.id, "allowed-ips", + newAllowedIPs, "preshared-key", (uid if pskExist else "/dev/null")] + updateAllowedIp = RunCommand(cmd, require_root=True) if pskExist: os.remove(uid) if len(updateAllowedIp.decode().strip("\n")) != 0: return False, "Update peer failed when updating Allowed IPs" - saveConfig = subprocess.check_output(f"{self.configuration.Protocol}-quick save {self.configuration.Name}", - shell=True, stderr=subprocess.STDOUT) + saveConfig = RunCommand([f"{self.configuration.Protocol}-quick", "save", self.configuration.Name], + require_root=True) if f"wg showconf {self.configuration.Name}" not in saveConfig.decode().strip('\n'): return False, "Update peer failed when saving the configuration" @@ -89,4 +89,4 @@ class AmneziaWGPeer(Peer): self.configuration.getPeers() return True, None except subprocess.CalledProcessError as exc: - return False, exc.output.decode("UTF-8").strip() \ No newline at end of file + return False, exc.output.decode("UTF-8").strip() diff --git a/src/modules/AmneziaWireguardConfiguration.py b/src/modules/AmneziaWireguardConfiguration.py index 6ada7d5f..3395960c 100644 --- a/src/modules/AmneziaWireguardConfiguration.py +++ b/src/modules/AmneziaWireguardConfiguration.py @@ -6,7 +6,7 @@ from flask import current_app from .PeerJobs import PeerJobs from .AmneziaWGPeer import AmneziaWGPeer from .PeerShareLinks import PeerShareLinks -from .Utilities import RegexMatch +from .Utilities import RegexMatch, RunCommand from .WireguardConfiguration import WireguardConfiguration from .DashboardWebHooks import DashboardWebHooks @@ -293,13 +293,14 @@ class AmneziaWireguardConfiguration(WireguardConfiguration): with open(uid, "w+") as f: f.write(p['preshared_key']) - subprocess.check_output( - f"{self.Protocol} set {self.Name} peer {p['id']} allowed-ips {p['allowed_ip'].replace(' ', '')}{f' preshared-key {uid}' if presharedKeyExist else ''}", - shell=True, stderr=subprocess.STDOUT) + cmd = [self.Protocol, "set", self.Name, "peer", p['id'], "allowed-ips", + p['allowed_ip'].replace(' ', '')] + if presharedKeyExist: + cmd += ["preshared-key", uid] + RunCommand(cmd, require_root=True) if presharedKeyExist: os.remove(uid) - subprocess.check_output( - f"{self.Protocol}-quick save {self.Name}", shell=True, stderr=subprocess.STDOUT) + RunCommand([f"{self.Protocol}-quick", "save", self.Name], require_root=True) self.getPeers() for p in peers: p = self.searchPeer(p['id']) @@ -319,4 +320,4 @@ class AmneziaWireguardConfiguration(WireguardConfiguration): with self.engine.connect() as conn: restricted = conn.execute(self.peersRestrictedTable.select()).mappings().fetchall() for i in restricted: - self.RestrictedPeers.append(AmneziaWGPeer(i, self)) \ No newline at end of file + self.RestrictedPeers.append(AmneziaWGPeer(i, self)) diff --git a/src/modules/Peer.py b/src/modules/Peer.py index 9201a9f0..3addbcaf 100644 --- a/src/modules/Peer.py +++ b/src/modules/Peer.py @@ -11,7 +11,7 @@ import jinja2 import sqlalchemy as db from .PeerJob import PeerJob from .PeerShareLink import PeerShareLink -from .Utilities import GenerateWireguardPublicKey, ValidateIPAddressesWithRange, ValidateDNSAddress +from .Utilities import GenerateWireguardPublicKey, ValidateIPAddressesWithRange, ValidateDNSAddress, RunCommand class Peer: @@ -94,15 +94,15 @@ class Peer: with open(uid, "w+") as f: f.write(preshared_key) newAllowedIPs = allowed_ip.replace(" ", "") - updateAllowedIp = subprocess.check_output( - f"{self.configuration.Protocol} set {self.configuration.Name} peer {self.id} allowed-ips {newAllowedIPs} {f'preshared-key {uid}' if pskExist else 'preshared-key /dev/null'}", - shell=True, stderr=subprocess.STDOUT) + cmd = [self.configuration.Protocol, "set", self.configuration.Name, "peer", self.id, "allowed-ips", + newAllowedIPs, "preshared-key", (uid if pskExist else "/dev/null")] + updateAllowedIp = RunCommand(cmd, require_root=True) if pskExist: os.remove(uid) if len(updateAllowedIp.decode().strip("\n")) != 0: return False, "Update peer failed when updating Allowed IPs" - saveConfig = subprocess.check_output(f"{self.configuration.Protocol}-quick save {self.configuration.Name}", - shell=True, stderr=subprocess.STDOUT) + saveConfig = RunCommand([f"{self.configuration.Protocol}-quick", "save", self.configuration.Name], + require_root=True) if f"wg showconf {self.configuration.Name}" not in saveConfig.decode().strip('\n'): return False, "Update peer failed when saving the configuration" with self.configuration.engine.begin() as conn: @@ -351,4 +351,4 @@ class Peer: hours, remainder = divmod(delta.total_seconds(), 3600) minutes, seconds = divmod(remainder, 60) - return f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}" \ No newline at end of file + return f"{int(hours):02}:{int(minutes):02}:{int(seconds):02}" diff --git a/src/modules/Utilities.py b/src/modules/Utilities.py index 0ba24066..c655133c 100644 --- a/src/modules/Utilities.py +++ b/src/modules/Utilities.py @@ -1,4 +1,4 @@ -import re, ipaddress +import re, ipaddress, os, shutil import subprocess @@ -68,18 +68,25 @@ def ValidateEndpointAllowedIPs(IPs) -> tuple[bool, str] | tuple[bool, None]: return False, str(e) return True, None +def RunCommand(cmd: list[str], input_data: bytes | None = None, require_root: bool = False) -> bytes: + exe = shutil.which(cmd[0]) if not os.path.isabs(cmd[0]) else cmd[0] + if exe: + cmd = [exe] + cmd[1:] + if require_root and os.geteuid() != 0: + sudo_path = shutil.which("sudo") or "/usr/sbin/sudo" + cmd = [sudo_path, "--non-interactive"] + cmd + return subprocess.check_output(cmd, input=input_data, stderr=subprocess.STDOUT) + def GenerateWireguardPublicKey(privateKey: str) -> tuple[bool, str] | tuple[bool, None]: try: - publicKey = subprocess.check_output(f"wg pubkey", input=privateKey.encode(), shell=True, - stderr=subprocess.STDOUT) + publicKey = RunCommand(["wg", "pubkey"], input_data=privateKey.encode()) return True, publicKey.decode().strip('\n') except subprocess.CalledProcessError: return False, None def GenerateWireguardPrivateKey() -> tuple[bool, str] | tuple[bool, None]: try: - publicKey = subprocess.check_output(f"wg genkey", shell=True, - stderr=subprocess.STDOUT) + publicKey = RunCommand(["wg", "genkey"]) return True, publicKey.decode().strip('\n') except subprocess.CalledProcessError: return False, None @@ -101,4 +108,4 @@ def ValidatePasswordStrength(password: str) -> tuple[bool, str] | tuple[bool, No if not re.search(r'[$&+,:;=?@#|\'<>.\-^*()%!~_-]', password): return False, "Password must contain at least 1 special character from $&+,:;=?@#|'<>.-^*()%!~_-" - return True, None \ No newline at end of file + return True, None diff --git a/src/modules/WireguardConfiguration.py b/src/modules/WireguardConfiguration.py index f1fdfe16..9fff24f2 100644 --- a/src/modules/WireguardConfiguration.py +++ b/src/modules/WireguardConfiguration.py @@ -16,7 +16,7 @@ from .Peer import Peer from .PeerJobs import PeerJobs from .PeerShareLinks import PeerShareLinks from .Utilities import StringToBoolean, GenerateWireguardPublicKey, RegexMatch, ValidateDNSAddress, \ - ValidateEndpointAllowedIPs + ValidateEndpointAllowedIPs, RunCommand from .WireguardConfigurationInfo import WireguardConfigurationInfo, PeerGroupsClass from .DashboardWebHooks import DashboardWebHooks @@ -67,6 +67,8 @@ class WireguardConfiguration: self.engine: sqlalchemy.Engine = sqlalchemy.create_engine(ConnectionString("wgdashboard")) self.metadata: sqlalchemy.MetaData = sqlalchemy.MetaData() self.dbType = self.DashboardConfig.GetConfig("Database", "type")[1] + self._realtime_rate_samples: dict[str, dict] = {} + self._realtime_rates: dict[str, dict] = {} if name is not None: if data is not None and "Backup" in data.keys(): @@ -496,6 +498,50 @@ class WireguardConfiguration: "time": datetime.now() }) ) + + def getPeersDailyUsage(self, peer_ids: list[str], day: datetime.date): + if not peer_ids: + return {} + if not self.configurationInfo.PeerTrafficTracking: + return {pid: {"total": 0, "sent": 0, "receive": 0} for pid in peer_ids} + + start = datetime(day.year, day.month, day.day, 0, 0, 0, 0) + end = start + timedelta(days=1) - timedelta(microseconds=1) + + total_expr = self.peersTransferTable.c.cumu_data + self.peersTransferTable.c.total_data + sent_expr = self.peersTransferTable.c.cumu_sent + self.peersTransferTable.c.total_sent + receive_expr = self.peersTransferTable.c.cumu_receive + self.peersTransferTable.c.total_receive + + usage = {pid: {"total": 0, "sent": 0, "receive": 0} for pid in peer_ids} + with self.engine.connect() as conn: + rows = conn.execute( + sqlalchemy.select( + self.peersTransferTable.c.id.label("id"), + sqlalchemy.func.max(total_expr).label("max_total"), + sqlalchemy.func.min(total_expr).label("min_total"), + sqlalchemy.func.max(sent_expr).label("max_sent"), + sqlalchemy.func.min(sent_expr).label("min_sent"), + sqlalchemy.func.max(receive_expr).label("max_receive"), + sqlalchemy.func.min(receive_expr).label("min_receive"), + ).where( + sqlalchemy.and_( + self.peersTransferTable.c.id.in_(peer_ids), + self.peersTransferTable.c.time >= start, + self.peersTransferTable.c.time <= end, + ) + ).group_by( + self.peersTransferTable.c.id + ) + ).mappings().fetchall() + for row in rows: + pid = row["id"] + if pid in usage: + usage[pid] = { + "total": max((row["max_total"] or 0) - (row["min_total"] or 0), 0), + "sent": max((row["max_sent"] or 0) - (row["min_sent"] or 0), 0), + "receive": max((row["max_receive"] or 0) - (row["min_receive"] or 0), 0), + } + return usage def logPeersHistoryEndpoint(self): with self.engine.begin() as conn: @@ -560,12 +606,14 @@ class WireguardConfiguration: with open(uid, "w+") as f: f.write(p['preshared_key']) - subprocess.check_output(f"{self.Protocol} set {self.Name} peer {p['id']} allowed-ips {p['allowed_ip'].replace(' ', '')}{f' preshared-key {uid}' if presharedKeyExist else ''}", - shell=True, stderr=subprocess.STDOUT) + cmd = [self.Protocol, "set", self.Name, "peer", p['id'], "allowed-ips", + p['allowed_ip'].replace(' ', '')] + if presharedKeyExist: + cmd += ["preshared-key", uid] + RunCommand(cmd, require_root=True) if presharedKeyExist: os.remove(uid) - subprocess.check_output( - f"{self.Protocol}-quick save {self.Name}", shell=True, stderr=subprocess.STDOUT) + RunCommand([f"{self.Protocol}-quick", "save", self.Name], require_root=True) self.getPeers() for p in peers: p = self.searchPeer(p['id']) @@ -615,8 +663,11 @@ class WireguardConfiguration: with open(uid, "w+") as f: f.write(restrictedPeer['preshared_key']) - subprocess.check_output(f"{self.Protocol} set {self.Name} peer {restrictedPeer['id']} allowed-ips {restrictedPeer['allowed_ip'].replace(' ', '')}{f' preshared-key {uid}' if presharedKeyExist else ''}", - shell=True, stderr=subprocess.STDOUT) + cmd = [self.Protocol, "set", self.Name, "peer", restrictedPeer['id'], "allowed-ips", + restrictedPeer['allowed_ip'].replace(' ', '')] + if presharedKeyExist: + cmd += ["preshared-key", uid] + RunCommand(cmd, require_root=True) if presharedKeyExist: os.remove(uid) else: return False, "Failed to allow access of peer " + i @@ -636,8 +687,7 @@ class WireguardConfiguration: found, pf = self.searchPeer(p) if found: try: - subprocess.check_output(f"{self.Protocol} set {self.Name} peer {pf.id} remove", - shell=True, stderr=subprocess.STDOUT) + RunCommand([self.Protocol, "set", self.Name, "peer", pf.id, "remove"], require_root=True) conn.execute( self.peersRestrictedTable.insert().from_select( [c.name for c in self.peersTable.columns], @@ -688,8 +738,7 @@ class WireguardConfiguration: AllPeerShareLinks.updateLinkExpireDate(shareLink.ShareID, datetime.now()) if found: try: - subprocess.check_output(f"{self.Protocol} set {self.Name} peer {pf.id} remove", - shell=True, stderr=subprocess.STDOUT) + RunCommand([self.Protocol, "set", self.Name, "peer", pf.id, "remove"], require_root=True) conn.execute( self.peersTable.delete().where( self.peersTable.columns.id == pf.id @@ -719,7 +768,7 @@ class WireguardConfiguration: def __wgSave(self) -> tuple[bool, str] | tuple[bool, None]: try: - subprocess.check_output(f"{self.Protocol}-quick save {self.Name}", shell=True, stderr=subprocess.STDOUT) + RunCommand([f"{self.Protocol}-quick", "save", self.Name], require_root=True) return True, None except subprocess.CalledProcessError as e: return False, str(e) @@ -728,8 +777,7 @@ class WireguardConfiguration: if not self.getStatus(): self.toggleConfiguration() try: - latestHandshake = subprocess.check_output(f"{self.Protocol} show {self.Name} latest-handshakes", - shell=True, stderr=subprocess.STDOUT) + latestHandshake = RunCommand([self.Protocol, "show", self.Name, "latest-handshakes"], require_root=True) except subprocess.CalledProcessError: return "stopped" latestHandshake = latestHandshake.decode("UTF-8").split() @@ -768,8 +816,7 @@ class WireguardConfiguration: if not self.getStatus(): self.toggleConfiguration() # try: - data_usage = subprocess.check_output(f"{self.Protocol} show {self.Name} transfer", - shell=True, stderr=subprocess.STDOUT) + data_usage = RunCommand([self.Protocol, "show", self.Name, "transfer"], require_root=True) data_usage = data_usage.decode("UTF-8").split("\n") data_usage = [p.split("\t") for p in data_usage] @@ -791,7 +838,11 @@ class WireguardConfiguration: cur_total_receive = float(data_usage[i][1]) / (1024 ** 3) cumulative_receive = cur_i['cumu_receive'] + total_receive cumulative_sent = cur_i['cumu_sent'] + total_sent - if total_sent <= cur_total_sent and total_receive <= cur_total_receive: + # Allow minor floating-point drift to avoid false rollover. + epsilon_gb = 0.0005 # ~0.5 MB + sent_diff = cur_total_sent - total_sent + recv_diff = cur_total_receive - total_receive + if sent_diff >= -epsilon_gb and recv_diff >= -epsilon_gb: total_sent = cur_total_sent total_receive = cur_total_receive else: @@ -821,13 +872,54 @@ class WireguardConfiguration: ) + def getPeersRealtimeRates(self): + if not self.getStatus(): + self.toggleConfiguration() + try: + data_usage = RunCommand([self.Protocol, "show", self.Name, "transfer"], require_root=True) + except subprocess.CalledProcessError: + return {} + now = time.time() + data_usage = data_usage.decode("UTF-8").split("\n") + data_usage = [p.split("\t") for p in data_usage] + rates = {} + for row in data_usage: + if len(row) == 3: + peer_id = row[0] + recv_bytes = float(row[1]) + sent_bytes = float(row[2]) + last = self._realtime_rate_samples.get(peer_id) + sent_bps = 0.0 + recv_bps = 0.0 + if last: + dt = now - last.get("ts", now) + if dt > 0: + sent_delta = sent_bytes - last.get("sent", sent_bytes) + recv_delta = recv_bytes - last.get("recv", recv_bytes) + if sent_delta < 0: + sent_delta = 0 + if recv_delta < 0: + recv_delta = 0 + sent_bps = sent_delta / dt + recv_bps = recv_delta / dt + rates[peer_id] = { + "sent_bps": sent_bps, + "recv_bps": recv_bps, + "updated_at": now + } + self._realtime_rate_samples[peer_id] = { + "sent": sent_bytes, + "recv": recv_bytes, + "ts": now + } + self._realtime_rates = rates + return rates def getPeersEndpoint(self): if not self.getStatus(): self.toggleConfiguration() try: - data_usage = subprocess.check_output(f"{self.Protocol} show {self.Name} endpoints", - shell=True, stderr=subprocess.STDOUT) + data_usage = RunCommand([self.Protocol, "show", self.Name, "endpoints"], require_root=True) except subprocess.CalledProcessError: return "stopped" data_usage = data_usage.decode("UTF-8").split() @@ -847,14 +939,13 @@ class WireguardConfiguration: self.getStatus() if self.Status: try: - check = subprocess.check_output(f"{self.Protocol}-quick down {self.Name}", - shell=True, stderr=subprocess.STDOUT) + check = RunCommand([f"{self.Protocol}-quick", "down", self.Name], require_root=True) self.removeAutostart() except subprocess.CalledProcessError as exc: return False, str(exc.output.strip().decode("utf-8")) else: try: - check = subprocess.check_output(f"{self.Protocol}-quick up {self.Name}", shell=True, stderr=subprocess.STDOUT) + check = RunCommand([f"{self.Protocol}-quick", "up", self.Name], require_root=True) self.addAutostart() except subprocess.CalledProcessError as exc: return False, str(exc.output.strip().decode("utf-8")) @@ -1291,4 +1382,4 @@ class WireguardConfiguration: conn.execute(sqlalchemy.text('VACUUM;')) except Exception as e: return False - return True \ No newline at end of file + return True