diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66356c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Byte-compiles files +**/__pycache__/ + +# Distribution / packaging +build/ +dist/ +*.egg-info/ + +# Environments +env/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..32b7297 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,25 @@ +image: python:3.7-buster + +cache: + key: ${CI_PROJECT_NAME} + paths: + - .cache/pip + - venv + +stages: + - test + +before_script: + - apt update + - apt install -y python3-dev + - pip install -U pip + - pip install virtualenv + - virtualenv venv + - source venv/bin/activate + - pip install -U black isort + +test:formatting: + stage: test + script: + - black --check . + - isort --check-only --diff . diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0401e31 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,15 @@ +repos: + - repo: local + hooks: + - id: black + name: black + entry: black + language: system + types: [python] + - repo: local + hooks: + - id: isort + name: isort + entry: isort + language: system + types: [python] diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..abd5abe --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Arcanite Solutions LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a69c873 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# wg-tray + +wg-tray enables to quickly bring up/down wireguard interfaces from the system tray, using `wg` and `wg-quick`. + +## Installation + +`$ pip install wg-tray` + +## Usage +```bash +wg-tray [-h] [-v] [-c CONFIG] + +optional arguments: + -h, --help show this help message and exit + -v, --version show program version info and exit + -c CONFIG, --config CONFIG path to the config file listing all wireguard interfaces + (default: none; use root privileges to look up in /etc/wireguard/) +``` +The config file should simply list all wireguard interfaces, separated either by newlines or spaces (e.g. `wg0 wg1` or +``` +wg0 +wg1 +``` +). The purpose of this config file is to avoid using root access on `ls` to read in `/etc`; however, its correctness is not checked and it is your responsability to keep it up to date. +If no config is provided, the interfaces are dynamically looked up in `/etc/wireguard`. + +If you want to avoid being prompted for your root password each time you run `wg-tray`, use a config file and add the following lines to your sudoers file (`sudo visudo` to edit), replacing `` with your user name: +``` + ALL=(ALL) NOPASSWD: /usr/bin/wg + ALL=(ALL) NOPASSWD: /usr/bin/wg-quick +``` diff --git a/bin/wg-tray b/bin/wg-tray new file mode 100644 index 0000000..a573849 --- /dev/null +++ b/bin/wg-tray @@ -0,0 +1,3 @@ +#! /usr/bin/env python + +from wgtray import wgtray diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0ee075d --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,40 @@ +[build-system] +requires = ["setuptools>=40.8.0", "wheel"] +build-backend = "setuptools.build_meta" + +[tool.black] +line-length = 150 + +[tool.isort] +# When imports are broken into multi-line, use the "Vertical Hanging Indent" layout. +multi_line_output = 3 + +# Always add a trailing comma to import lists (default: False). +include_trailing_comma = true + +# Always put imports lists into vertical mode (0 = none allowed on first line) +force_grid_wrap = 0 + +# When multi-lining imports, use parentheses for line-continuation instead of default \. +use_parentheses = true + +# Max import line length. +line_length = 150 + +# Regardless of what follows the imports, force 2 blank lines after the import list +lines_after_imports = 2 + +# Insert 2 blank lines between each section +lines_between_sections = 2 + +# Alphabetical sort in sections (inside a line or in ()) +force_alphabetical_sort_within_sections = true + +# Sort by lexicographical +lexicographical = true + +# Put all from before import +from_first = true + +ensure_newline_before_comments = true + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..297c8c7 --- /dev/null +++ b/setup.py @@ -0,0 +1,31 @@ +import pathlib + + +from setuptools import find_packages, setup + + +# Get the long description from the README file +here = pathlib.Path(__file__).parent.resolve() +long_description = (here / "README.md").read_text() + +# Get the global variables of the package +gv = {} +exec((here / "wgtray/__init__.py").read_text(), gv) + +setup( + name="wg-tray", + version=gv["__version__"], + description="A simple graphical tool to manage wireguard interfaces from the system tray", + long_description=long_description, + long_description_content_type="text/markdown", + author="Elsa Weber", + author_email="elsa.weber@arcanite.ch", + url="https://git.arcanite.ch/arcanite/wg-tray/", + license="LICENSE.txt", + packages=find_packages(), + install_requires=[ + "pyqt5", + ], + package_data={"wgtray": ["res/*"]}, + scripts=["bin/wg-tray"], +) diff --git a/wgtray/__init__.py b/wgtray/__init__.py new file mode 100644 index 0000000..e077bfb --- /dev/null +++ b/wgtray/__init__.py @@ -0,0 +1,2 @@ +__version__ = "0.1" +__description__ = "A simple UI tool to handle WireGuard interfaces" diff --git a/wgtray/actions/__init__.py b/wgtray/actions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/wgtray/actions/interface.py b/wgtray/actions/interface.py new file mode 100644 index 0000000..09e27bc --- /dev/null +++ b/wgtray/actions/interface.py @@ -0,0 +1,66 @@ +import pathlib +import subprocess +import threading + + +from PyQt5.QtCore import pyqtSignal, pyqtSlot +from PyQt5.QtGui import QIcon, QMovie +from PyQt5.QtWidgets import QAction, QSystemTrayIcon + + +RES_PATH = pathlib.Path(__file__).parent.parent.resolve() / "res" + + +class WGInterface(QAction): + done = pyqtSignal(bool, str, name="done") # necessary to put outside of __init__ + + def __init__(self, name, parent, is_up): + super().__init__(name, parent) + + self.name = name + self.parent = parent + self.is_up = is_up + + self.triggered.connect(self.toggle) + self.done.connect(self.check_status) + + def setUp(self, up): + self.is_up = up + + def updateIcon(self): + if self.is_up: + icon_path = f"{RES_PATH}/green_arrow_up.png" + else: + icon_path = f"{RES_PATH}/grey_arrow_down.png" + self.setIcon(QIcon(icon_path)) + + def toggle(self): + # Loading animation + self.loadingSpinner = QMovie(f"{RES_PATH}/loader.gif") + self.loadingSpinner.frameChanged.connect(lambda: self.setIcon(QIcon(self.loadingSpinner.currentPixmap()))) + self.loadingSpinner.start() + # Launch command + t = threading.Thread(target=self.bring_up_down, args=(self.is_up,)) + t.start() + + @pyqtSlot(bool, str) + def check_status(self, is_up, err_message): + self.is_up = is_up + + if err_message: + self.parent.tray.showMessage("Error", err_message, QSystemTrayIcon.NoIcon) + self.loadingSpinner.stop() + self.updateIcon() + + def bring_up_down(self, is_up): + kw = "down" if is_up else "up" + subp = subprocess.Popen(f"sudo wg-quick {kw} {self.name}", stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + + _, std_err = subp.communicate() # blocking + stat, err_msg = is_up, "" + if subp.returncode == 0: + stat = not is_up # toggle was successful + else: + err_msg = std_err.decode() + + self.done.emit(stat, err_msg) diff --git a/wgtray/res/green_arrow_up.png b/wgtray/res/green_arrow_up.png new file mode 100644 index 0000000..704a3b5 Binary files /dev/null and b/wgtray/res/green_arrow_up.png differ diff --git a/wgtray/res/grey_arrow_down.png b/wgtray/res/grey_arrow_down.png new file mode 100644 index 0000000..779c2af Binary files /dev/null and b/wgtray/res/grey_arrow_down.png differ diff --git a/wgtray/res/icon.png b/wgtray/res/icon.png new file mode 100644 index 0000000..da178b0 Binary files /dev/null and b/wgtray/res/icon.png differ diff --git a/wgtray/res/loader.gif b/wgtray/res/loader.gif new file mode 100644 index 0000000..218f488 Binary files /dev/null and b/wgtray/res/loader.gif differ diff --git a/wgtray/wgtray.py b/wgtray/wgtray.py new file mode 100644 index 0000000..f1501dc --- /dev/null +++ b/wgtray/wgtray.py @@ -0,0 +1,168 @@ +import argparse +import os +import pathlib +import signal +import sys + + +from PyQt5.QtCore import pyqtSlot, QCoreApplication, QTimer +from PyQt5.QtGui import QContextMenuEvent, QIcon, QMovie +from PyQt5.QtWidgets import QAction, QApplication, QMenu, QSystemTrayIcon + + +from . import __description__, __version__ +from .actions.interface import WGInterface + + +RES_PATH = pathlib.Path(__file__).parent.resolve() / "res" + + +class WGTrayIcon(QSystemTrayIcon): + def __init__(self, config_path=None): + super().__init__() + + self.menu = WGMenu(self, config_path) + self.setContextMenu(self.menu) + self.activated.connect(self.activateMenu) # show also on left click + + self.setIcon(QIcon(f"{RES_PATH}/icon.png")) + + self.show() + + @pyqtSlot(QSystemTrayIcon.ActivationReason) + def activateMenu(self, activationReason): + if activationReason == QSystemTrayIcon.Trigger: + self.menu.showTearOff() + + +class WGMenu(QMenu): + def __init__(self, tray, config_path): + super().__init__() + + self.tray = tray + + if config_path: + with open(config_path) as f: + itfs = f.read() + else: + itfs = os.popen("sudo ls /etc/wireguard | grep .conf | awk -F \".\" '{print $1}'").read() + itfs_up = self.read_status() + + for itf_name in itfs.strip().split(): + action = WGInterface(itf_name, self, itf_name in itfs_up) + action.updateIcon() + self.addAction(action) + + self.addSeparator() + + # Options for the tear off menu + + # Refresh data + self.refresh = QAction("Refresh", self, triggered=self.startRefresh) + self.addAction(self.refresh) + self.refresh.setVisible(False) + + self.refreshTimer = QTimer() + self.refreshTimer.timeout.connect(self.stopRefresh) + + # Close menu + self.closeTearOff = QAction("Close menu", self, triggered=self.closeMenu) + self.addAction(self.closeTearOff) + self.closeTearOff.setVisible(False) + + self.addAction(QAction("Quit", self, triggered=self.quit)) + + self.aboutToShow.connect(self.preshowMenu) + + def read_status(self): + return os.popen("sudo wg | grep interface | awk '{print $2}'").read().strip().split() + + def reloadStatus(self): + itfs_up = self.read_status() + for action in self.actions(): + if isinstance(action, WGInterface): + action.setUp(action.text() in itfs_up) + action.updateIcon() + + def showTearOff(self): + self.showTearOffMenu(QApplication.desktop().availableGeometry().center()) + self.closeTearOff.setVisible(True) + self.refresh.setVisible(True) + + # Find window popup + tornPopup = None + for tl in QApplication.topLevelWidgets(): + if tl.metaObject().className() == "QTornOffMenu": + tornPopup = tl + break + + if tornPopup: + # Center window + screen = QApplication.desktop().availableGeometry() + qtRectangle = tornPopup.frameGeometry() + qtRectangle.moveCenter(screen.center()) + tornPopup.move(qtRectangle.topLeft()) + + @pyqtSlot() + def preshowMenu(self): + if not self.isTearOffMenuVisible(): + self.closeTearOff.setVisible(False) + self.refresh.setVisible(False) + self.updateGeometry() + + self.reloadStatus() + + @pyqtSlot() + def startRefresh(self): + # Launch timer for 0.5 seconds + self.refreshTimer.start(500) + # Loading animation + self.refreshSpinner = QMovie(f"{RES_PATH}/loader.gif") + self.refreshSpinner.frameChanged.connect(lambda: self.refresh.setIcon(QIcon(self.refreshSpinner.currentPixmap()))) + self.refreshSpinner.start() + # Actual refresh + self.reloadStatus() + + @pyqtSlot() + def stopRefresh(self): + self.refreshSpinner.stop() + self.refresh.setIcon(QIcon()) + self.refreshTimer.stop() + + @pyqtSlot() + def closeMenu(self): + self.closeTearOff.setVisible(False) + self.refresh.setVisible(False) + self.hideTearOffMenu() + + @pyqtSlot() + def quit(self): + QCoreApplication.quit() + + +def parse_args(): + + parser = argparse.ArgumentParser(description=__description__) + parser.add_argument("-v", "--version", help="show program version info and exit", action="version", version=__version__) + parser.add_argument( + "-c", + "--config", + help="path to the config file listing all WireGuard interfaces (if none is provided, use root privileges to look up in /etc/wireguard/)", + default=None, + type=str, + ) + + args = parser.parse_args() + + return args.config + + +app = QApplication(sys.argv) + +config_path = parse_args() + +WGTrayIcon(config_path) + +signal.signal(signal.SIGINT, signal.SIG_DFL) + +sys.exit(app.exec_())