├── .github ├── dependabot.yml └── workflows │ └── pyinstaller.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── config.yml ├── core ├── __init__.py ├── healthcheck.py ├── manager.py ├── process.py ├── wireguard.py └── wstunnel.py ├── gui ├── __init__.py ├── interface.py └── logger.py ├── images └── screenshot.png ├── main.py └── requirements.txt /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/pyinstaller.yml: -------------------------------------------------------------------------------- 1 | name: Pyinstaller Build 2 | on: 3 | push: 4 | branches: [main, "dev"] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | include: 14 | - os: macos-latest 15 | TARGET: macos_arm64 16 | 17 | - os: windows-latest 18 | TARGET: windows_x86_64 19 | 20 | - os: ubuntu-latest 21 | TARGET: linux_x86_64 22 | 23 | steps: 24 | - uses: actions/checkout@v2 25 | 26 | - name: Setup Python 27 | uses: actions/setup-python@v2 28 | with: 29 | python-version: 3.11 30 | 31 | - name: Install dependencies 32 | run: python -m pip install -r requirements.txt pyinstaller 33 | 34 | - name: Build with pyinstaller 35 | run: pyinstaller main.py --onefile --hide-console hide-late --uac-admin 36 | #--add-data=assets:assets 37 | 38 | - name: copy config file 39 | run: cp config.yml dist/ 40 | 41 | - uses: actions/upload-artifact@v4 42 | with: 43 | name: wgwst_${{ matrix.TARGET }} 44 | path: | 45 | dist/* 46 | 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User 2 | *.conf 3 | 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | dns.json 165 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "main", 6 | "type": "python", 7 | "request": "launch", 8 | "program": "main.py", 9 | "args": ["--config","config.yml","--log_level","DEBUG"], 10 | "console": "integratedTerminal", 11 | "justMyCode": true, 12 | } 13 | ] 14 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.typeCheckingMode": "basic", 3 | "python.analysis.autoImportCompletions": true, 4 | "cSpell.words": [ 5 | "installtunnelservice", 6 | "runas", 7 | "uninstalltunnelservice" 8 | ] 9 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Clement 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wireguard over wstunnel (TCP) 2 | 3 | ## About The Project 4 | 5 | This is a Python application that quickly and easily enables the use of Wireguard over TCP using [wstunnel](https://github.com/erebe/wstunnel). 6 | 7 | ![alt text](images/screenshot.png) 8 | 9 | ## Getting Started 10 | 11 | ### Prerequisites 12 | 13 | This script requires the following software to be installed/downloaded: 14 | 15 | - [Python 3](https://www.python.org/downloads/) 16 | - [wstunnel](https://github.com/erebe/wstunnel/releases) 17 | - [Wireguard](https://www.wireguard.com/install/) 18 | 19 | ## Build 20 | 21 | To create a standalone binary using PyInstaller, follow these steps: 22 | 23 | 1. Download the latest [release](https://github.com/klementng/wireguard-over-wstunnel/releases/) source code or clone the main branch. 24 | 2. Install dependencies: 25 | 26 | ```bash 27 | python -m pip install -r requirements.txt pyinstaller 28 | ``` 29 | 30 | 3. Build the binary: 31 | 32 | ```bash 33 | pyinstaller main.py --onefile --hide-console hide-late --uac-admin 34 | ``` 35 | 36 | 4. The output binary will be located in the `dist` directory. 37 | 38 |

(back to top)

39 | 40 | ### Installation 41 | 42 | #### Using PyInstaller Binaries 43 | 44 | 1. Download the latest [release](https://github.com/klementng/wireguard-over-wstunnel/releases/) binary. 45 | 2. Change permissions (for Linux systems): 46 | 47 | ```sh 48 | chmod +x ./main 49 | ``` 50 | 51 | 3. Edit the [config.yml](./config.yml). 52 | 4. Start the program (double-click on Windows): 53 | 54 | ```sh 55 | ./main 56 | ``` 57 | 58 | #### Using Source Code 59 | 60 | 1. Download the latest [release](https://github.com/klementng/wireguard-over-wstunnel/releases/) source code. 61 | 2. Extract the ZIP file. 62 | 3. Install required packages: 63 | 64 | ```sh 65 | pip install -r requirements.txt 66 | ``` 67 | 68 | 4. Edit the [config.yml](./config.yml). 69 | 5. Start the program: 70 | 71 | ```sh 72 | python main.py 73 | ``` 74 | 75 |

(back to top)

76 | 77 | ## Command Line Usage 78 | 79 | Additional Options: 80 | 81 | ```text 82 | usage: main.py [-h] [--config CONFIG] [--clean] [--export] [--nogui] [--log_level LOG_LEVEL] 83 | 84 | Wireguard over wstunnel 85 | 86 | options: 87 | -h, --help show this help message and exit 88 | --config CONFIG, -c CONFIG 89 | path to program config 90 | --nogui start with no GUI 91 | ``` 92 | 93 |

(back to top)

94 | 95 | ## License 96 | 97 | Distributed under the MIT License. See `LICENSE.txt` for more information. 98 | 99 |

(back to top)

100 | -------------------------------------------------------------------------------- /config.yml: -------------------------------------------------------------------------------- 1 | app: 2 | logging: 3 | file: output.log 4 | level: INFO 5 | 6 | wstunnel: # see https://github.com/erebe/wstunnel 7 | path: 8 | windows: .\wstunnel.exe 9 | linux: ./wstunnel 10 | darwin: ./wstunnel 11 | 12 | args: 13 | # Required 14 | server: wss://www.example.com:443 15 | local-to-remote: udp://0.0.0.0:51820:127.0.0.1:51820?timeout_sec=0 16 | 17 | # Optional 18 | #http-upgrade-path-prefix: ... 19 | #http-upgrade-credentials: ... 20 | #tls-sni-override: wss.server.example.com 21 | 22 | wireguard: 23 | path: 24 | windows: C:\Program Files\WireGuard\wireguard.exe 25 | linux: /usr/bin/wg-quick 26 | darwin: /usr/local/bin/wg-quick 27 | 28 | config: 29 | #path: ./test.conf 30 | str: 31 | | # Ignored if 'path' is set, AllowedIPs & Address will be replaced automatically 32 | [Interface] 33 | PrivateKey= 34 | Address= 35 | DNS=1.1.1.1, 8.8.8.8 36 | 37 | [Peer] 38 | PublicKey= 39 | PresharedKey= 40 | AllowedIPs = 0.0.0.0/0, ::/0 41 | PersistentKeepalive = 20 42 | Endpoint = 127.0.0.1:51820 43 | 44 | healthcheck: 45 | ip: # Number of tries to fetch public IP at the start of connection (stops after 5 times) 46 | enabled: True 47 | interval: 15 48 | restart: 49 | wstunnel: False 50 | wireguard: False 51 | 52 | ping: # Send a ping to wstunnel server every n secs 53 | enabled: True 54 | interval: 30 55 | restart: 56 | wstunnel: True 57 | wireguard: False 58 | 59 | state: # monitors wstunnel process state and check if wireguard tunnels are installed 60 | enabled: True 61 | interval: 5 62 | restart: 63 | wstunnel: True 64 | wireguard: True 65 | -------------------------------------------------------------------------------- /core/__init__.py: -------------------------------------------------------------------------------- 1 | from .wireguard import * 2 | from .wstunnel import * 3 | from .healthcheck import * 4 | from .manager import * 5 | -------------------------------------------------------------------------------- /core/healthcheck.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import platform 3 | import subprocess 4 | import threading 5 | from abc import ABC, abstractmethod 6 | from dataclasses import dataclass 7 | 8 | import requests 9 | 10 | from .process import Process 11 | from .wireguard import WireguardProcess 12 | from .wstunnel import WstunnelProcess 13 | 14 | 15 | @dataclass 16 | class HealthCheckConfig: 17 | enabled: bool = True 18 | interval: int = 60 19 | restart_wstunnel: bool = False 20 | restart_wireguard: bool = False 21 | 22 | @classmethod 23 | def init(cls, config_dict): 24 | return cls( 25 | enabled=config_dict.get("enabled", True), 26 | interval=config_dict.get("interval", 10), 27 | restart_wstunnel=config_dict.get("restart", {}).get("wstunnel", False), 28 | restart_wireguard=config_dict.get("restart", {}).get("wireguard", False), 29 | ) 30 | 31 | 32 | class HealthCheckProcess(Process): 33 | def __init__( 34 | self, 35 | config: HealthCheckConfig, 36 | wireguard: WireguardProcess, 37 | wstunnel: WstunnelProcess, 38 | ): 39 | self.wireguard = wireguard 40 | self.wstunnel = wstunnel 41 | 42 | self.enabled = config.enabled 43 | self.interval = config.interval 44 | 45 | self.restart_wg = config.restart_wireguard 46 | self.restart_wst = config.restart_wstunnel 47 | 48 | self.status = "not started" if self.enabled else "disabled" 49 | self.status_msg = "" 50 | 51 | self.logger = logging.getLogger(self.__class__.__name__) 52 | self.process = None 53 | 54 | def get_status(self): 55 | if self.status_msg: 56 | return self.status + ":" + self.status_msg 57 | else: 58 | return self.status 59 | 60 | def start(self): 61 | if self.enabled: 62 | self.process = threading.Timer(self.interval, self._monitor) 63 | self.logger.debug("Starting health check.") 64 | self.process.start() 65 | 66 | def stop(self): 67 | if self.enabled and self.process: 68 | self.logger.debug("Stopping health check.") 69 | self.process.cancel() 70 | self.status = "stopped" 71 | 72 | def _monitor(self): 73 | status = self.test() 74 | self.status = "healthy" if status else "unhealthy" 75 | 76 | try: 77 | self.logger.debug("Performing health check...") 78 | if not status: 79 | self.logger.warning("Health check failed.") 80 | self.handle_failure() 81 | except Exception as e: 82 | self.logger.error(f"Error during health check: {e}") 83 | finally: 84 | self.process = threading.Timer(self.interval, self._monitor) 85 | self.process.daemon = True 86 | self.process.start() 87 | 88 | def handle_failure(self): 89 | if self.restart_wg and self.wireguard: 90 | self.logger.info("Restarting Wireguard...") 91 | self.wireguard.restart() 92 | 93 | if self.restart_wst and self.wstunnel: 94 | self.logger.info("Restarting WStunnel...") 95 | self.wstunnel.restart() 96 | 97 | @abstractmethod 98 | def test(self) -> bool: 99 | """Perform the specific health check. Return True if healthy, False otherwise.""" 100 | pass 101 | 102 | 103 | class HealthCheckPing(HealthCheckProcess): 104 | def __init__( 105 | self, 106 | config: HealthCheckConfig, 107 | wireguard: WireguardProcess, 108 | wstunnel: WstunnelProcess, 109 | ): 110 | super().__init__(config, wireguard, wstunnel) 111 | self.servers = [self.wstunnel.config.endpoint_ip, "1.1.1.1", "8.8.8.8"] 112 | self.flag = "-n" if platform.system() == "Windows" else "-c" 113 | 114 | def test(self) -> bool: 115 | for server in self.servers: 116 | res = subprocess.call( 117 | ["ping", self.flag, "1", server], stdout=subprocess.PIPE 118 | ) 119 | if res == 0: 120 | self.logger.debug(f"Ping successful to server: {server}") 121 | return True 122 | self.logger.warning(f"Failed to ping any server: {self.servers}") 123 | return False 124 | 125 | 126 | class HealthCheckState(HealthCheckProcess): 127 | def test(self) -> bool: 128 | wg_is_healthy = self.wireguard.is_running if self.wireguard else False 129 | wst_is_healthy = self.wstunnel.is_running if self.wstunnel else False 130 | 131 | if not wg_is_healthy: 132 | self.logger.critical("Wireguard process is down!") 133 | if not wst_is_healthy: 134 | self.logger.critical("Wstunnel process is down!") 135 | 136 | return wg_is_healthy and wst_is_healthy 137 | 138 | 139 | class HealthCheckIP(HealthCheckProcess): 140 | def __init__( 141 | self, 142 | config: HealthCheckConfig, 143 | wireguard: WireguardProcess, 144 | wstunnel: WstunnelProcess, 145 | ): 146 | super().__init__(config, wireguard, wstunnel) 147 | self.max_retries = 5 148 | self.start_ip = self.fetch_ip() 149 | self.runs = 0 150 | 151 | def start(self): 152 | self.runs = 0 153 | 154 | return super().start() 155 | 156 | def _monitor(self): 157 | result = self.test() 158 | self.status = "healthy" if result else "unhealthy" 159 | self.runs += 1 160 | 161 | try: 162 | if result or self.runs >= self.max_retries: 163 | self.process.cancel() 164 | self.logger.debug( 165 | "Stopping IP health check based on result or max retries." 166 | ) 167 | self.process.cancel() 168 | self.status = "exited" 169 | else: 170 | self.process = threading.Timer(self.interval, self._monitor) 171 | self.process.daemon = True 172 | self.process.start() 173 | except Exception as e: 174 | self.logger.error(f"Error in IP monitor: {e}") 175 | 176 | def test(self) -> bool: 177 | new_ip = self.fetch_ip() 178 | 179 | if new_ip is None: 180 | self.logger.warning( 181 | "Failed to fetch Public IP. Your traffic may not be tunneled!" 182 | ) 183 | self.status_msg = "failed" 184 | return False 185 | 186 | if self.start_ip is None: 187 | self.status_msg = "unknown" 188 | self.logger.warning( 189 | "Unknown Status! Unable to compare old IP with new IP. " 190 | "Your traffic may not be tunneled!" 191 | ) 192 | return True 193 | 194 | if self.start_ip == new_ip: 195 | self.status_msg = "unchanged" 196 | self.logger.warning( 197 | f"IP unchanged: {new_ip}. Your traffic may not be tunneled!" 198 | ) 199 | return False 200 | 201 | self.status_msg = "success" 202 | self.logger.info(f"Public IP changed to: {new_ip}") 203 | return True 204 | 205 | def fetch_ip(self) -> str | None: 206 | try: 207 | res = requests.get("https://api.ipify.org", timeout=2) 208 | res.raise_for_status() 209 | return res.text 210 | except Exception as e: 211 | self.logger.debug("Unable to fetch Public IP.") 212 | self.logger.debug(e) 213 | 214 | return None 215 | -------------------------------------------------------------------------------- /core/manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from .process import Process 4 | 5 | 6 | class ProcessManager: 7 | def __init__(self): 8 | self.processes: list[Process] = [] 9 | self.logger = logging.getLogger("Manager") 10 | 11 | def add(self, process: Process): 12 | self.processes.append(process) 13 | 14 | def get_statues(self): 15 | statuses = [] 16 | for p in self.processes: 17 | statuses.append((p.__class__.__name__, p.get_status())) 18 | 19 | return statuses 20 | 21 | def start(self): 22 | for p in self.processes: 23 | p.start() 24 | 25 | def stop(self): 26 | for process in reversed(self.processes): 27 | process.stop() 28 | 29 | def __del__(self): 30 | self.logger.info("Cleaning up processes...") 31 | for process in reversed(self.processes): 32 | try: 33 | process.stop() 34 | except Exception as e: 35 | self.logger.error("Error stopping process: %s", e) 36 | self.processes.clear() 37 | -------------------------------------------------------------------------------- /core/process.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, abstractmethod 2 | 3 | 4 | class Process(ABC): 5 | @abstractmethod 6 | def start(self): 7 | pass 8 | 9 | @abstractmethod 10 | def stop(self): 11 | pass 12 | 13 | @abstractmethod 14 | def get_status(self): 15 | pass 16 | -------------------------------------------------------------------------------- /core/wireguard.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import ipaddress 3 | import logging 4 | import os 5 | import platform 6 | import subprocess 7 | import time 8 | from dataclasses import dataclass 9 | 10 | import psutil 11 | from wgconfig import WGConfig 12 | 13 | from .process import Process 14 | from .wstunnel import WstunnelConfig 15 | 16 | CONFIG_FOLDER = "./conf/" 17 | 18 | 19 | class WireguardError(Exception): 20 | pass 21 | 22 | 23 | class WireguardConfigError(WireguardError): 24 | pass 25 | 26 | 27 | class WireguardRuntimeError(WireguardError): 28 | pass 29 | 30 | 31 | @dataclass 32 | class WireguardConfig: 33 | exec_path: str 34 | exec_up_args: list[str] 35 | exec_down_args: list[str] 36 | iface_name: str 37 | conf_path: str 38 | conf: WGConfig 39 | 40 | @staticmethod 41 | def init(config: dict, wst_config: WstunnelConfig) -> "WireguardConfig": 42 | 43 | exec_path = WireguardConfig.parse_exec_path(config) 44 | conf_path, iface_name, wg_config = WireguardConfig.create_config_file( 45 | config, wst_config 46 | ) 47 | start, stop = WireguardConfig.generate_exec_args( 48 | exec_path, iface_name, conf_path 49 | ) 50 | 51 | return WireguardConfig( 52 | exec_path=exec_path, 53 | exec_up_args=start, 54 | exec_down_args=stop, 55 | iface_name=iface_name, 56 | conf_path=conf_path, 57 | conf=wg_config, 58 | ) 59 | 60 | @staticmethod 61 | def parse_exec_path(config): 62 | try: 63 | plat = platform.system().lower() 64 | path = config["path"].get(plat) 65 | 66 | except KeyError as e: 67 | raise WireguardConfigError(f"Config key 'path' for {plat} is not set {e}") 68 | 69 | return path 70 | 71 | @staticmethod 72 | def generate_exec_args(exec_path: str, iface_name: str, wg_path: str): 73 | 74 | up = "/installtunnelservice" if platform.system() == "Windows" else "up" 75 | down = "/uninstalltunnelservice" if platform.system() == "Windows" else "down" 76 | path = iface_name if platform.system() == "Windows" else wg_path 77 | 78 | return [exec_path, up, wg_path], [exec_path, down, path] 79 | 80 | @staticmethod 81 | def create_config_file(config, wst: WstunnelConfig): 82 | 83 | try: 84 | wg_path = config["config"].get("path") 85 | 86 | if wg_path is not None: 87 | with open(wg_path, encoding="utf-8") as f: 88 | wg_str = f.read() 89 | else: 90 | wg_str = config["config"]["str"] 91 | 92 | str_hash = hashlib.md5(wg_str.encode()).hexdigest() 93 | iface_name = f"wg-wst-{str_hash[0:8]}" 94 | conf_path = os.path.join(CONFIG_FOLDER, f"{iface_name}.conf") 95 | conf_path = os.path.abspath(conf_path) 96 | 97 | os.makedirs(os.path.dirname(conf_path), exist_ok=True) 98 | 99 | with open(conf_path, "w", encoding="utf-8") as f: 100 | f.write(wg_str) 101 | 102 | wg_config = WGConfig(conf_path) 103 | wg_config.read_file() 104 | 105 | allowed_ips = [] 106 | peer_id = list(wg_config.peers.keys())[0] # type: ignore 107 | 108 | for ips in wg_config.peers[peer_id]["AllowedIPs"]: # type: ignore 109 | 110 | if ips == "::/0" and platform.system().lower() == "windows": 111 | continue 112 | 113 | try: 114 | net1 = ipaddress.ip_network(ips) 115 | net2 = ipaddress.ip_network(wst.endpoint_ip + "/32") 116 | allowed_ips.extend(map(str, net1.address_exclude(net2))) # type: ignore 117 | 118 | except (TypeError, ValueError) as e: 119 | allowed_ips.append(ips) 120 | 121 | if platform.system().lower() == "windows": 122 | allowed_ips.extend(["::/1", "8000::/1"]) 123 | 124 | allowed_ips = ", ".join(set(allowed_ips)) 125 | wg_config.del_attr(peer_id, "AllowedIPs") 126 | wg_config.add_attr(peer_id, "AllowedIPs", allowed_ips) 127 | 128 | listen_ip = "127.0.0.1" if wst.listen_ip == "0.0.0.0" else wst.listen_ip 129 | 130 | wg_config.del_attr(peer_id, "Endpoint") 131 | wg_config.add_attr(peer_id, "Endpoint", f"{listen_ip}:{wst.listen_port}") 132 | 133 | wg_config.write_file(conf_path) 134 | 135 | return conf_path, iface_name, wg_config 136 | 137 | except Exception as e: 138 | raise WireguardConfigError( 139 | f"An error occurred processing wireguard file: {e}" 140 | ) 141 | 142 | 143 | class WireguardProcess(Process): 144 | def __init__(self, config: WireguardConfig) -> None: 145 | self.log = logging.getLogger("WireguardProcess") 146 | self.config = config 147 | 148 | def get_status(self): 149 | return "running" if self.is_running else "stopped" 150 | 151 | @property 152 | def is_running(self): 153 | return self.config.iface_name in psutil.net_if_addrs() 154 | 155 | def reset(self): 156 | if self.is_running: 157 | self.log.warning("Found unstopped wireguard interface, removing...") 158 | self.stop() 159 | 160 | def start(self): 161 | self.reset() 162 | self.log.info("Starting wireguard...") 163 | 164 | try: 165 | subprocess.run(self.config.exec_up_args, check=True) 166 | self.log.info("Started wireguard!") 167 | 168 | except subprocess.CalledProcessError as e: 169 | raise WireguardRuntimeError(f"Unable to start wireguard {e}") 170 | 171 | def stop(self): 172 | if self.is_running: 173 | try: 174 | self.log.info("Stopping wireguard...") 175 | subprocess.run(self.config.exec_down_args, check=True) 176 | 177 | while self.is_running: 178 | time.sleep(0.1) 179 | 180 | self.log.info("Stopped wireguard!") 181 | time.sleep(3) # wait some time for wireguard cli to work 182 | 183 | except subprocess.CalledProcessError as e: 184 | raise WireguardRuntimeError(f"Unable to stop wireguard. {e}") 185 | 186 | def restart(self): 187 | self.log.info("Restarting...") 188 | self.stop() 189 | 190 | while self.is_running: 191 | time.sleep(0.1) 192 | 193 | self.start() 194 | -------------------------------------------------------------------------------- /core/wstunnel.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import platform 5 | import re 6 | import signal 7 | import socket 8 | import subprocess 9 | import threading 10 | import time 11 | from dataclasses import dataclass 12 | 13 | from .process import Process 14 | 15 | 16 | class WstunnelError(Exception): 17 | pass 18 | 19 | 20 | class WstunnelConfigError(WstunnelError): 21 | pass 22 | 23 | 24 | class WstunnelRuntimeError(WstunnelError): 25 | pass 26 | 27 | 28 | @dataclass 29 | class WstunnelConfig: 30 | exec_path: str 31 | exec_args: list[str] 32 | server: str 33 | host: str 34 | endpoint_port: int 35 | endpoint_ip: str 36 | proto: str 37 | listen_ip: str 38 | listen_port: int 39 | remote_ip: str 40 | remote_port: int 41 | connect_args: str 42 | 43 | @staticmethod 44 | def init(config: dict) -> "WstunnelConfig": 45 | 46 | path = WstunnelConfig.parse_exec_path(config) 47 | server, host, endpoint_port, endpoint_ip = WstunnelConfig.parse_server_settings( 48 | config 49 | ) 50 | proto, listen_ip, listen_port, remote_ip, remote_port, connect_args = ( 51 | WstunnelConfig.parse_tunnel_settings(config) 52 | ) 53 | return WstunnelConfig( 54 | exec_path=path, 55 | exec_args=WstunnelConfig.generate_exec_args(config), 56 | server=server, 57 | host=host, 58 | endpoint_port=endpoint_port, 59 | endpoint_ip=endpoint_ip, 60 | proto=proto, 61 | listen_ip=listen_ip, 62 | listen_port=listen_port, 63 | remote_ip=remote_ip, 64 | remote_port=remote_port, 65 | connect_args=connect_args, 66 | ) 67 | 68 | @staticmethod 69 | def parse_exec_path(config): 70 | plat = platform.system().lower() 71 | try: 72 | path = config["path"][plat] 73 | except KeyError: 74 | raise WstunnelConfigError(f"Config key 'path' for {plat} is not set") 75 | return path 76 | 77 | @staticmethod 78 | def generate_exec_args(config): 79 | wst_args = [WstunnelConfig.parse_exec_path(config), "client"] 80 | 81 | args = config.get("args").copy() 82 | server = args.pop("server") 83 | 84 | for arg_name, arg_data in args.items(): 85 | flag = "-" if len(arg_name) == 1 else "--" 86 | 87 | if isinstance(arg_data, bool) and arg_data is True: 88 | wst_args.append(f"{flag}{arg_data}") 89 | else: 90 | wst_args.append(f"{flag}{arg_name}={arg_data}") 91 | 92 | wst_args.append(server) 93 | 94 | return wst_args 95 | 96 | @staticmethod 97 | def parse_server_settings(config): 98 | try: 99 | server = config["args"]["server"] 100 | except KeyError: 101 | raise WstunnelConfigError("Config key 'server' is not set") 102 | 103 | if "www.example.com" in server: 104 | raise WstunnelConfigError("Please set the server in the config") 105 | 106 | match = re.match( 107 | r"^(?Pwss|ws|https|http):\/\/(?P[^:\/]+)(:(?P\d+))?", 108 | server, 109 | ) 110 | 111 | if not match: 112 | raise WstunnelConfigError("Invalid server format") 113 | 114 | endpoint_proto = match.group("proto") 115 | host = match.group("host") 116 | endpoint_port = int( 117 | match.group("port") or (443 if endpoint_proto in ["wss", "https"] else 80) 118 | ) 119 | endpoint_ip = WstunnelConfig.resolve_dns(host) 120 | 121 | return server, host, endpoint_port, endpoint_ip 122 | 123 | @staticmethod 124 | def resolve_dns(host, cache_file="conf/dns.json"): 125 | cache = {} 126 | if os.path.exists(cache_file): 127 | with open(cache_file, "r") as file: 128 | cache = json.load(file) 129 | 130 | try: 131 | ip = socket.gethostbyname(host) 132 | cache[host] = ip 133 | with open(cache_file, "w") as file: 134 | json.dump(cache, file) 135 | except socket.gaierror: 136 | if host in cache: 137 | ip = cache[host] 138 | else: 139 | raise WstunnelConfigError("Unable to resolve DNS.") 140 | 141 | return ip 142 | 143 | @staticmethod 144 | def parse_tunnel_settings(config): 145 | try: 146 | local_to_remote = config["args"]["local-to-remote"] 147 | except KeyError: 148 | raise WstunnelConfigError("Config key 'local-to-remote' is not set") 149 | 150 | match = re.match( 151 | r"^(?Pudp|tcp):\/\/(?P[^:]+):(?P\d+):(?P[^:]+):(?P\d+)\??(?P.*)?", 152 | local_to_remote, 153 | ) 154 | 155 | if not match: 156 | raise WstunnelConfigError("Invalid local_to_remote format") 157 | 158 | proto = match.group("proto") 159 | listen_ip = match.group("listen_ip") 160 | listen_port = int(match.group("listen_port")) 161 | remote_ip = match.group("remote_ip") 162 | remote_port = int(match.group("remote_port")) 163 | args = match.group("args") 164 | 165 | return proto, listen_ip, listen_port, remote_ip, remote_port, args 166 | 167 | 168 | class WstunnelProcess(Process): 169 | def __init__(self, config: WstunnelConfig) -> None: 170 | self.log = logging.getLogger("WstunnelProcess") 171 | self.config = config 172 | 173 | self.process: subprocess.Popen = None # type: ignore 174 | self.process_logger_thread = None # type: ignore 175 | 176 | def _logger_process(self): 177 | while self.is_running: 178 | output_std = self.process.stdout.readline() # type: ignore 179 | if output_std is not None: 180 | self.log.info(str(output_std.strip(), "utf-8")) 181 | time.sleep(0.01) 182 | 183 | def get_status(self): 184 | return "running" if self.is_running else "stopped" 185 | 186 | @property 187 | def is_running(self): 188 | return self.process and self.process.poll() is None 189 | 190 | def start(self): 191 | 192 | self.log.info("Starting...") 193 | 194 | kw = { 195 | "stdout": subprocess.PIPE, 196 | "stderr": subprocess.PIPE, 197 | # "shell": True, 198 | } 199 | 200 | if platform.system() == "Windows": 201 | kw.setdefault("creationflags", subprocess.CREATE_NEW_PROCESS_GROUP) 202 | else: 203 | kw.setdefault("preexec_fn", os.setsid) # type: ignore 204 | 205 | self.process = subprocess.Popen(self.config.exec_args, **kw) # type: ignore 206 | 207 | start_time = time.time() 208 | timeout = 3 # seconds 209 | 210 | while time.time() - start_time < timeout: 211 | if self.is_running: 212 | self.process_logger_thread = threading.Thread( 213 | target=self._logger_process, daemon=True 214 | ) 215 | 216 | self.process_logger_thread.start() 217 | 218 | time.sleep(3) 219 | # begin dns resolution for wstunnel 220 | target_ip = ( 221 | "127.0.0.1" 222 | if self.config.listen_ip == "0.0.0.0" 223 | else self.config.listen_ip 224 | ) 225 | with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: 226 | sock.sendto( 227 | "start".encode(), 228 | (target_ip, self.config.listen_port), 229 | ) 230 | 231 | time.sleep(3) 232 | 233 | self.log.info("Started wstunnel!") 234 | 235 | return 236 | 237 | time.sleep(0.1) 238 | 239 | self.log.critical( 240 | "Unable to start wstunnel. Process returned a status code of: %s. %s", 241 | self.process.returncode, 242 | self.process.stderr.read().decode().strip(), # type: ignore 243 | ) 244 | 245 | raise WstunnelRuntimeError("Unable to start wstunnel") 246 | 247 | def stop(self): 248 | 249 | if self.is_running: 250 | self.log.info("Stopping...") 251 | 252 | if platform.system() == "Windows": 253 | self.log.debug("Stopping using CTRL_BREAK_EVENT") 254 | self.process.send_signal(signal.CTRL_BREAK_EVENT) 255 | else: 256 | self.log.debug("Stopping using SIGTERM") 257 | self.process.terminate() 258 | 259 | # Wait for the process to terminate 260 | try: 261 | self.process.wait(timeout=10) 262 | except subprocess.TimeoutExpired: 263 | self.log.warning( 264 | "Process did not terminate within 10 seconds, killing it" 265 | ) 266 | self.process.kill() 267 | self.process.wait() 268 | 269 | self.log.info("Stopped wstunnel") 270 | 271 | def restart(self): 272 | self.log.info("Restarting...") 273 | self.stop() 274 | time.sleep(1) 275 | self.start() 276 | 277 | def cleanup(self): 278 | self.stop() 279 | -------------------------------------------------------------------------------- /gui/__init__.py: -------------------------------------------------------------------------------- 1 | from .interface import * 2 | -------------------------------------------------------------------------------- /gui/interface.py: -------------------------------------------------------------------------------- 1 | import re 2 | import threading 3 | import time 4 | import customtkinter as ctk 5 | import pystray 6 | from PIL import Image, ImageDraw 7 | import logging 8 | import tkinter as tk 9 | 10 | from .logger import LogToGUIHandler 11 | 12 | from core import ProcessManager, manager 13 | 14 | ctk.set_appearance_mode("System") 15 | ctk.set_default_color_theme("blue") 16 | 17 | 18 | class Interface(ctk.CTk): 19 | def __init__(self, process_manager: ProcessManager): 20 | super().__init__() 21 | 22 | self.process_manager: ProcessManager = process_manager 23 | 24 | # Configure main window 25 | self.title("Wireguard over Wstunnel") 26 | self.geometry("800x600") 27 | self.grid_rowconfigure(0, weight=1) 28 | self.grid_columnconfigure(1, weight=1) 29 | 30 | # Override close button behavior 31 | self.protocol("WM_DELETE_WINDOW", self.minimize_to_tray) 32 | 33 | # Sidebar Frame 34 | self.sidebar_frame = ctk.CTkFrame(self, width=200, corner_radius=0) 35 | self.sidebar_frame.grid(row=0, column=0, sticky="nswe") 36 | self.sidebar_frame.grid_rowconfigure(4, weight=1) 37 | 38 | self.nav_logo_label = ctk.CTkLabel( 39 | self.sidebar_frame, 40 | text="Menu", 41 | font=ctk.CTkFont(size=20, weight="bold"), 42 | ) 43 | self.nav_logo_label.grid(row=0, column=0, padx=20, pady=(20, 10)) 44 | 45 | self.nav_home_btn = ctk.CTkButton( 46 | self.sidebar_frame, text="Home", command=self.home_button_event 47 | ) 48 | self.nav_home_btn.grid(row=1, column=0, padx=20, pady=10) 49 | 50 | self.nav_logs_btn = ctk.CTkButton( 51 | self.sidebar_frame, text="Logs", command=self.logs_button_event 52 | ) 53 | self.nav_logs_btn.grid(row=3, column=0, padx=20, pady=10) 54 | 55 | self.nav_mode_label = ctk.CTkLabel(self.sidebar_frame, text="Appearance Mode:") 56 | self.nav_mode_label.grid(row=5, column=0, padx=20, pady=(10, 0)) 57 | 58 | self.nav_mode_options_menu = ctk.CTkOptionMenu( 59 | self.sidebar_frame, 60 | values=["System", "Dark", "Light"], 61 | command=self.change_appearance_mode, 62 | ) 63 | self.nav_mode_options_menu.grid(row=6, column=0, padx=20, pady=(0, 20)) 64 | 65 | # Main Content Frame 66 | self.main_frame = ctk.CTkFrame(self, corner_radius=0) 67 | self.main_frame.grid(row=0, column=1, sticky="nswe") 68 | self.main_frame.grid_rowconfigure(0, weight=1) 69 | self.main_frame.grid_columnconfigure(0, weight=1) 70 | 71 | # Logs Frame 72 | self.logs_frame = ctk.CTkFrame( 73 | self, corner_radius=10, border_width=2, border_color="#444444" 74 | ) 75 | self.logs_frame.grid(row=0, column=1, sticky="nswe") 76 | self.logs_frame.grid_rowconfigure(0, weight=1) 77 | self.logs_frame.grid_columnconfigure(0, weight=1) 78 | 79 | self.logs_textbox = tk.Text( 80 | self.logs_frame, 81 | wrap="word", 82 | state="disabled", 83 | bg="#222222", 84 | fg="#ffffff", 85 | font=("Consolas", 12), 86 | ) 87 | self.logs_textbox.grid(row=0, column=0, sticky="nsew", padx=20, pady=20) 88 | 89 | self.logs_scrollbar = tk.Scrollbar( 90 | self.logs_frame, command=self.logs_textbox.yview 91 | ) 92 | self.logs_scrollbar.grid(row=0, column=1, sticky="ns") 93 | self.logs_textbox.configure(yscrollcommand=self.logs_scrollbar.set) 94 | 95 | # Setup logging 96 | self.log_handler = LogToGUIHandler(self.logs_textbox) 97 | self.log_handler.setFormatter( 98 | logging.Formatter("%(levelname)s - %(name)s - %(message)s") 99 | ) 100 | logging.root.addHandler(self.log_handler) 101 | 102 | self.log = logging.getLogger("TkinterGUI") 103 | self.log.info("Logging initialized.") 104 | 105 | # Exit Button 106 | self.exit_button = ctk.CTkButton( 107 | self.main_frame, 108 | text="Exit", 109 | fg_color="red", 110 | hover_color="#b30000", 111 | width=50, 112 | height=30, 113 | corner_radius=10, 114 | command=self.exit_application, 115 | ) 116 | self.exit_button.place(relx=0.95, rely=0.05, anchor="ne") 117 | 118 | # Add Table to Main Frame 119 | self.table_frame = ctk.CTkFrame( 120 | self.main_frame, corner_radius=10, border_width=2, border_color="#444444" 121 | ) 122 | self.table_frame.place(relx=0.5, rely=0.20, anchor="n") 123 | 124 | self.table_label_1 = ctk.CTkLabel( 125 | self.table_frame, 126 | text="Name", 127 | font=ctk.CTkFont(size=14, weight="bold"), 128 | text_color="orange", 129 | corner_radius=5, 130 | ) 131 | self.table_label_1.grid(row=0, column=0, padx=10, pady=5, sticky="ew") 132 | 133 | self.table_label_2 = ctk.CTkLabel( 134 | self.table_frame, 135 | text="Status", 136 | font=ctk.CTkFont(size=14, weight="bold"), 137 | text_color="orange", 138 | corner_radius=5, 139 | ) 140 | self.table_label_2.grid(row=0, column=1, padx=10, pady=5, sticky="ew") 141 | 142 | self.tables_content = [] 143 | 144 | for i, (name, status) in enumerate(self.process_manager.get_statues()): 145 | 146 | a = ctk.CTkLabel( 147 | self.table_frame, 148 | text=name, 149 | font=ctk.CTkFont(size=12), 150 | corner_radius=5, 151 | ) 152 | a.grid(row=i + 1, column=0, padx=10, pady=5, sticky="ew") 153 | b = ctk.CTkLabel( 154 | self.table_frame, 155 | text=status, 156 | font=ctk.CTkFont(size=12), 157 | corner_radius=5, 158 | ) 159 | b.grid(row=i + 1, column=1, padx=10, pady=5, sticky="ew") 160 | 161 | self.tables_content.append((a, b)) 162 | 163 | def update_table(): 164 | while True: 165 | for i, (name, status) in enumerate(self.process_manager.get_statues()): 166 | a, b = self.tables_content[i] 167 | b.configure(text=status) 168 | 169 | time.sleep(1) 170 | 171 | threading.Thread(target=update_table, daemon=True).start() 172 | 173 | # Connection Status 174 | self.title_bar = ctk.CTkLabel( 175 | self.main_frame, 176 | text="Stopped", 177 | font=ctk.CTkFont(size=22, weight="bold"), 178 | text_color="white", 179 | fg_color="#333333", 180 | corner_radius=10, 181 | padx=20, 182 | pady=10, 183 | ) 184 | self.title_bar.place(relx=0.5, rely=0.10, anchor="center") 185 | 186 | # Connect Button 187 | self.is_connect_changing = False 188 | self.connect_button = ctk.CTkButton( 189 | self.main_frame, 190 | width=150, 191 | height=150, 192 | corner_radius=50, 193 | fg_color="#0080ff", 194 | hover_color="#0056b3", 195 | border_width=3, 196 | text_color="white", 197 | font=ctk.CTkFont(size=16, weight="bold"), 198 | command=self.toggle_connection, 199 | ) 200 | self.connect_button.place(relx=0.5, rely=0.75, anchor="center") 201 | 202 | self.is_connected = False 203 | 204 | self.tray_icon = None 205 | self.create_tray_icon() 206 | 207 | self.home_button_event() 208 | 209 | def minimize_to_tray(self): 210 | """Withdraw the window and minimize to tray.""" 211 | self.withdraw() 212 | 213 | def create_tray_icon(self): 214 | """Create a system tray icon.""" 215 | image = Image.new("RGB", (64, 64), color=(0, 0, 0)) 216 | draw = ImageDraw.Draw(image) 217 | draw.ellipse((16, 16, 48, 48), fill="blue") 218 | self.tray_icon = pystray.Icon( 219 | "Wireguard", 220 | image, 221 | "Wireguard", 222 | menu=pystray.Menu( 223 | pystray.MenuItem("Show", self.show_window), 224 | ), 225 | ) 226 | self.tray_icon.run_detached() 227 | 228 | def show_window(self): 229 | """Show the main application window.""" 230 | self.deiconify() 231 | 232 | def exit_application(self): 233 | """Exit the application.""" 234 | if self.tray_icon: 235 | try: 236 | self.tray_icon.stop() 237 | except Exception as e: 238 | print(f"Error stopping tray icon: {e}") 239 | logging.root.removeHandler(self.log_handler) 240 | self.destroy() 241 | 242 | def toggle_connection(self): 243 | if self.is_connect_changing: 244 | self.connect_button.configure(text="Processing...", fg_color="gray") 245 | return 246 | 247 | def _start(): 248 | self.is_connect_changing = True 249 | self.process_manager.start() 250 | self.is_connect_changing = False 251 | 252 | self.is_connected = True 253 | self.home_button_event() 254 | 255 | def _stop(): 256 | self.is_connect_changing = True 257 | self.process_manager.stop() 258 | self.is_connect_changing = False 259 | 260 | self.is_connected = False 261 | self.home_button_event() 262 | 263 | if self.is_connected: 264 | threading.Thread(target=_stop).start() 265 | self.title_bar.configure(text="Stopping...", fg_color="gray") 266 | self.connect_button.configure(text="Processing...", fg_color="gray") 267 | else: 268 | threading.Thread(target=_start).start() 269 | self.title_bar.configure(text="Starting...", fg_color="gray") 270 | self.connect_button.configure(text="Processing...", fg_color="gray") 271 | 272 | def home_button_event(self): 273 | self.main_frame.tkraise() 274 | if self.is_connect_changing: 275 | text = "Stopping..." if self.is_connected else "Starting" 276 | self.title_bar.configure(text=text, fg_color="green") 277 | self.connect_button.configure(text="Processing...", fg_color="gray") 278 | 279 | if self.is_connected: 280 | self.title_bar.configure(text="Running", fg_color="green") 281 | self.connect_button.configure(text="Stop", fg_color="#DC143C") 282 | else: 283 | self.title_bar.configure(text="Stopped", fg_color="gray") 284 | self.connect_button.configure(text="Start", fg_color="blue") 285 | 286 | def logs_button_event(self): 287 | self.logs_frame.tkraise() 288 | 289 | def change_appearance_mode(self, new_mode): 290 | ctk.set_appearance_mode(new_mode) 291 | -------------------------------------------------------------------------------- /gui/logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import tkinter as tk 4 | 5 | 6 | class LogToGUIHandler(logging.Handler): 7 | """Used to redirect logging output to the widget passed in parameters""" 8 | 9 | def __init__(self, console): 10 | logging.Handler.__init__(self) 11 | self.console = console 12 | 13 | def emit(self, record): 14 | formatted_message = self.format(record) 15 | self.console.configure(state="normal") # Allow text editing temporarily 16 | self.console.insert("end", formatted_message + "\n") # Append log message 17 | self.console.configure(state="disabled") # Disable editing 18 | self.console.yview("end") # Scroll to the latest log entry 19 | -------------------------------------------------------------------------------- /images/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/klementng/wireguard-over-wstunnel/5ebc3547634c36c40f4256504b1f05ab7b036088/images/screenshot.png -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | import argparse 3 | import ctypes 4 | import logging 5 | import os 6 | import platform 7 | import sys 8 | import time 9 | 10 | import yaml 11 | 12 | from core import ( 13 | HealthCheckConfig, 14 | HealthCheckIP, 15 | HealthCheckPing, 16 | HealthCheckState, 17 | ProcessManager, 18 | WireguardConfig, 19 | WireguardProcess, 20 | WstunnelConfig, 21 | WstunnelProcess, 22 | ) 23 | 24 | from gui import Interface 25 | 26 | 27 | LOGGING_FORMAT = "%(levelname)s - %(name)s - %(message)s" 28 | 29 | logging.basicConfig( 30 | format=LOGGING_FORMAT, 31 | ) 32 | 33 | 34 | logger = logging.getLogger("main") 35 | 36 | 37 | def elevate_user(): 38 | logger.info("Elevating to superuser / admin") 39 | 40 | sys_os = platform.system() 41 | 42 | if sys_os == "Windows": 43 | if not ctypes.windll.shell32.IsUserAnAdmin(): # type: ignore 44 | ctypes.windll.shell32.ShellExecuteW( # type: ignore 45 | None, "runas", sys.executable, " ".join(sys.argv), None, 1 46 | ) 47 | sys.exit(0) 48 | 49 | elif sys_os in ["Linux", "Darwin"]: # linux mac os 50 | if os.geteuid() != 0: # type: ignore 51 | logger.info("Elevating via sudo: 'sudo echo'") 52 | os.system("sudo echo") 53 | 54 | else: 55 | logger.critical("Unknown/Unsupported OS.") 56 | sys.exit(1) 57 | 58 | 59 | def main(): 60 | parser = argparse.ArgumentParser(description="Wireguard over wstunnel") 61 | parser.add_argument( 62 | "--config", "-c", help="path to program config", default="./config.yml" 63 | ) 64 | parser.add_argument( 65 | "--clean", 66 | help="clean wireguard tunnel that are not properly stopped", 67 | action="store_true", 68 | ) 69 | parser.add_argument( 70 | "--nogui", 71 | help="start with no gui", 72 | action="store_true", 73 | ) 74 | 75 | args = parser.parse_args() 76 | 77 | logger.info("Detected OS: %s", platform.system()) 78 | with open(args.config, encoding="utf-8") as f: 79 | logger.info("Loading config...") 80 | config = yaml.safe_load(f) 81 | 82 | app_config = config["app"] 83 | 84 | logging.root.setLevel(app_config["logging"]["level"]) 85 | if app_config["logging"]["file"] is not None: 86 | fn = logging.FileHandler(app_config["logging"]["file"]) 87 | fn.setFormatter(logging.Formatter(LOGGING_FORMAT)) 88 | logging.root.addHandler(fn) 89 | 90 | wst_config = WstunnelConfig.init(config["wstunnel"]) 91 | wg_config = WireguardConfig.init(config["wireguard"], wst_config) 92 | hc_config = config["healthcheck"] 93 | 94 | wst = WstunnelProcess(wst_config) 95 | wg = WireguardProcess(wg_config) 96 | 97 | hc_ping = HealthCheckPing(HealthCheckConfig.init(hc_config["ping"]), wg, wst) 98 | hc_state = HealthCheckState(HealthCheckConfig.init(hc_config["state"]), wg, wst) 99 | hc_ip = HealthCheckIP(HealthCheckConfig.init(hc_config["ip"]), wg, wst) 100 | 101 | if args.clean is True: 102 | wg.reset() 103 | return 104 | 105 | else: 106 | elevate_user() 107 | 108 | manager = ProcessManager() 109 | manager.add(wst) # wst must start first 110 | manager.add(wg) 111 | manager.add(hc_ping) 112 | manager.add(hc_state) 113 | manager.add(hc_ip) 114 | try: 115 | if args.nogui: 116 | manager.start() 117 | else: 118 | gui = Interface(manager) 119 | gui.mainloop() 120 | finally: 121 | manager.stop() 122 | 123 | 124 | if __name__ == "__main__": 125 | 126 | try: 127 | main() 128 | except (KeyboardInterrupt, SystemExit): 129 | pass 130 | 131 | except Exception: 132 | logging.critical("Caught an exception. exiting...", exc_info=True) 133 | 134 | finally: 135 | time.sleep(1) 136 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | customtkinter==5.2.2 2 | Pillow==11.0.0 3 | psutil==6.1.1 4 | pystray==0.19.5 5 | PyYAML==6.0.2 6 | PyYAML==6.0.2 7 | Requests==2.32.3 8 | wgconfig==1.0.4 9 | --------------------------------------------------------------------------------