├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── VERSION ├── mypy.ini ├── openvpn_api ├── __init__.py ├── constants.py ├── models │ ├── __init__.py │ ├── state.py │ └── stats.py ├── util │ ├── __init__.py │ ├── errors.py │ └── logging.py └── vpn.py ├── pyproject.toml ├── setup.py └── tests ├── test_model_base.py ├── test_namespace.py ├── test_server_stats_model.py ├── test_state_model.py └── test_vpn_model.py /.gitignore: -------------------------------------------------------------------------------- 1 | ## PROJECT ############################################################################################################# 2 | .idea 3 | 4 | 5 | ## PYTHON ############################################################################################################## 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | build/ 17 | develop-eggs/ 18 | dist/ 19 | downloads/ 20 | eggs/ 21 | .eggs/ 22 | lib/ 23 | lib64/ 24 | parts/ 25 | sdist/ 26 | var/ 27 | wheels/ 28 | pip-wheel-metadata/ 29 | share/python-wheels/ 30 | *.egg-info/ 31 | .installed.cfg 32 | *.egg 33 | MANIFEST 34 | 35 | # PyInstaller 36 | # Usually these files are written by a python script from a template 37 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 38 | *.manifest 39 | *.spec 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | # Unit test / coverage reports 46 | htmlcov/ 47 | .tox/ 48 | .nox/ 49 | .coverage 50 | .coverage.* 51 | .cache 52 | nosetests.xml 53 | coverage.xml 54 | *.cover 55 | .hypothesis/ 56 | .pytest_cache/ 57 | cover/ 58 | 59 | # Translations 60 | *.mo 61 | *.pot 62 | 63 | # Django stuff: 64 | *.log 65 | local_settings.py 66 | db.sqlite3 67 | db.sqlite3-journal 68 | 69 | # Flask stuff: 70 | instance/ 71 | .webassets-cache 72 | 73 | # Scrapy stuff: 74 | .scrapy 75 | 76 | # Sphinx documentation 77 | docs/_build/ 78 | 79 | # PyBuilder 80 | target/ 81 | 82 | # Jupyter Notebook 83 | .ipynb_checkpoints 84 | 85 | # IPython 86 | profile_default/ 87 | ipython_config.py 88 | 89 | # pyenv 90 | .python-version 91 | 92 | # pipenv 93 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 94 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 95 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 96 | # install all needed dependencies. 97 | #Pipfile.lock 98 | 99 | # celery beat schedule file 100 | celerybeat-schedule 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: bionic 2 | language: python 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | - "3.8" 7 | - "3.9-dev" 8 | - "nightly" 9 | install: 10 | - python setup.py install 11 | - pip install nose black mypy 12 | script: 13 | - black --diff --check . 14 | - mypy . 15 | - nosetests --logging-level=DEBUG tests 16 | deploy: 17 | provider: pypi 18 | skip_existing: true 19 | user: jamie 20 | password: 21 | secure: "Ym/8F8pLsO88pQTJ3aNxF7wFyfbDDPHwAiH30aJv0XysH+V3lYL1f11L17yzuBJNWDo8sXBUzdjcLNT3JcVDHiwz1PPa7nliZEyDFz7W5+RkO3byu8Qe27AFsEM2Legzqj4lirRJbQ/GeuhYZUHAGzGUUTtC+bKMum4z/E0hmPNplGrwjtu3V+RXYbbwNJf79ZfSx8mQCWVp+7BmoXaHpk/SKiX6prvVc5z4CDSuyD37Emdt2jYMocEzap5Ip64iMMVIOD45OBJ+5oewywqm4AtznTO3GABLOL1T4w3VyrW2Q3fRNthscyBdpEDjFr4UoF+sNVhF3zqJ9i80elkjBdhtduygRuX9em22I8LsEGvzt5aTg+0XSmZMtz+bgoPbkSZ03oKZfaYPRi8KC4ySE6y+eJQy23eXdPrn/lB4dY6NjN0y6xt6bL8gAIyNnKaO2QfQhzWcswJ3zV/Ot79SuHBvTzZWh9nD7cKB1oHirEG/j7tFm7CTCXAGSAa768LqiSdJCMtwZhqgdyqj6vvRfDlKhUV8b0DcZ71UDRC98CAGa/Pkv73lJL9n6zJCA7FEIn8Z5VuS/6+S1Sb8E2/JFKvIXbWzAEgUdrtu+3pSW3/DFJ+8Zlomvk811/AeUScPjIcXePIL2pfz1bfaa96btKJMUXUouXLa8ha+LOzuHA0=" 22 | on: 23 | tags: true 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Project 2 | 3 | ## Branches and versioning 4 | All work is done in master, and `VERSION` in master shows the release we're currently working towards. 5 | Any bugfixes which need to be made should be made in master and cherry picked to release branches for the current release version if appropriate. 6 | Any non-forwards compatible fixes should be made only in the release branch for the given release. 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Jamie Scott 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 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include VERSION 2 | include LICENSE 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenVPN Management Interface Python API 2 | 3 | [![Build Status](https://travis-ci.org/Jamie-/openvpn-api.svg?branch=master)](https://travis-ci.org/Jamie-/openvpn-api) 4 | [![PyPI](https://img.shields.io/pypi/v/openvpn-api.svg)](https://pypi.org/project/openvpn-api/) 5 | 6 | ## Summary 7 | 8 | A Python API for interacting with the OpenVPN management interface. 9 | Currently a work in progress so support for client management interfaces and events is lacking. 10 | 11 | Very useful for extracting metrics and status from OpenVPN server management interfaces. 12 | 13 | This project was inspired by the work of Marcus Furlong in creating [openvpn-monitor](https://github.com/furlongm/openvpn-monitor). 14 | It also uses [openvpn-status](https://pypi.org/project/openvpn-status/) by Jiangge Zhang for parsing the output of the OpenVPN `status` command as there's no point reinventing the wheel when an excellent solution already exists. 15 | 16 | Release notes can be found [here on GitHub](https://github.com/Jamie-/openvpn-api/releases). 17 | 18 | :warning: Development work is done in master, if you wish to see the source for a release version, checkout the appropriate `releases/x.x.x` branch. 19 | The latest release version can be found here: [releases/0.3.x](https://github.com/Jamie-/openvpn-api/tree/releases/0.3.x). 20 | 21 | When using and developing this library, you may find the manual for the OpenVPN [management interface](https://openvpn.net/community-resources/controlling-a-running-openvpn-process/#using-the-management-interface) useful: https://openvpn.net/community-resources/management-interface/ 22 | 23 | 24 | ## Requirements 25 | This project requires Python >= 3.6. 26 | 27 | Other packages: 28 | * [openvpn-status](https://pypi.org/project/openvpn-status/) 29 | 30 | ## Installation 31 | 32 | #### Via PyPI 33 | ``` 34 | pip install openvpn-api 35 | ``` 36 | 37 | #### Via Source 38 | ``` 39 | git clone https://github.com/Jamie-/openvpn-api.git 40 | cd openvpn-api 41 | python setup.py install 42 | ``` 43 | 44 | ## Usage 45 | 46 | ### Introduction 47 | Create a `VPN` object for your management interface connection. 48 | ```python 49 | import openvpn_api.VPN 50 | v = openvpn_api.VPN('localhost', 7505) 51 | ``` 52 | 53 | Then you can either manage connection and disconnection yourself 54 | ```python 55 | v.connect() 56 | # Do some stuff, e.g. 57 | print(v.release) 58 | v.disconnect() 59 | ``` 60 | If the connection is successful, `v.connect()` will return `True`. 61 | However, if the connection fails `v.connect()` will raise an `openvpn_api.errors.ConnectError` exception with the reason for the connection failure. 62 | 63 | Or use the connection context manager 64 | ```python 65 | with v.connection(): 66 | # Do some stuff, e.g. 67 | print(v.release) 68 | ``` 69 | 70 | After initialising a VPN object, we can query specifics about it. 71 | 72 | We can get the address we're communicating to the management interface on 73 | ```python 74 | >>> v.mgmt_address 75 | 'localhost:7505' 76 | ``` 77 | 78 | And also see if this is via TCP/IP or a Unix socket 79 | ```python 80 | >>> v.type 81 | 'ip' 82 | ``` 83 | 84 | or 85 | ```python 86 | >>> v.type 87 | 'socket' 88 | ``` 89 | 90 | These are represented by the `VPNType` class as `VPNType.IP` or `VPNType.UNIX_SOCKET` 91 | ```python 92 | >>> v.type 93 | 'ip' 94 | >>> v.type == openvpn_api.VPNType.IP 95 | True 96 | ``` 97 | 98 | ### Daemon Interaction 99 | All the properties that get information about the OpenVPN service you're connected to are stateful. 100 | The first time you call one of these methods it caches the information it needs so future calls are super fast. 101 | The information cached is unlikely to change often, unlike the status and metrics we can also fetch which are likely to change very frequently. 102 | 103 | We can fetch the release string for the version of OpenVPN we're using 104 | ```python 105 | >>> v.release 106 | 'OpenVPN 2.4.4 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Sep 5 2018' 107 | ``` 108 | 109 | Or just the version number 110 | ```python 111 | >>> v.version 112 | '2.4.4' 113 | ``` 114 | 115 | The information cached by these 2 properties can be cleared and will be repopulated on the next call 116 | ```python 117 | v.clear_cache() 118 | ``` 119 | 120 | #### Daemon State 121 | 122 | We can get more information about the service by looking at it's state which is returned as a State object 123 | ```python 124 | >>> v.get_state() 125 | 126 | ``` 127 | 128 | The State object contains the following things: 129 | 130 | The daemon's current mode, `client` or `server` 131 | ```python 132 | >>> s = v.get_state() 133 | >>> s.mode 134 | 'server' 135 | ``` 136 | 137 | Date and time the daemon was started 138 | ```python 139 | >>> s.up_since 140 | datetime.datetime(2019, 6, 5, 23, 3, 21) 141 | ``` 142 | 143 | The daemon's current state 144 | ```python 145 | >>> s.state_name 146 | 'CONNECTED' 147 | ``` 148 | Which can be any of: 149 | * `CONNECTING` - OpenVPN's initial state. 150 | * `WAIT` - (Client only) Waiting for initial response from server. 151 | * `AUTH` - (Client only) Authenticating with server. 152 | * `GET_CONFIG` - (Client only) Downloading configuration options from server. 153 | * `ASSIGN_IP` - Assigning IP address to virtual network interface. 154 | * `ADD_ROUTES` - Adding routes to system. 155 | * `CONNECTED` - Initialization Sequence Completed. 156 | * `RECONNECTING` - A restart has occurred. 157 | * `EXITING` - A graceful exit is in progress. 158 | * `RESOLVE` - (Client only) DNS lookup 159 | * `TCP_CONNECT` - (Client only) Connecting to TCP server 160 | 161 | The descriptive string - unclear from the OpenVPN documentation quite what this is, usually `SUCCESS` or the reason for disconnection if the state is `RECONNECTING` or `EXITING` 162 | ```python 163 | >>> s.desc_string 164 | 'SUCCESS' 165 | ``` 166 | 167 | The daemon's local virtual (VPN internal) v4 address, returned as an `ipaddress.IPv4Address` for ease of sorting, it can be easily converted to a string with `str()` 168 | ```python 169 | >>> s.local_virtual_v4_addr 170 | IPv4Address('10.0.0.1') 171 | >>> str(s.local_virtual_v4_addr) 172 | '10.0.0.1' 173 | ``` 174 | 175 | If the daemon is in client mode, then `remote_addr` and `remote_port` will be populated with the address and port of the remote server 176 | ```python 177 | >>> s.remote_addr 178 | IPv4Address('1.2.3.4') 179 | >>> s.remote_port 180 | 1194 181 | ``` 182 | 183 | If the daemon is in server mode, then `local_addr` and `local_port` will be populated with the address and port of the exposed server 184 | ```python 185 | >>> s.local_addr 186 | IPv4Address('5.6.7.8') 187 | >>> s.local_port 188 | 1194 189 | ``` 190 | 191 | If the daemon is using IPv6 instead of, or in addition to, IPv4 then the there is also a field for the local virtual (VPN internal) v6 address 192 | ```python 193 | >>> s.local_virtual_v6_addr 194 | '2001:db8:85a3::8a2e:370:7334' 195 | ``` 196 | 197 | #### Daemon Status 198 | The daemon status is parsed from the management interface by `openvpn_status` an existing Python library for parsing the output from OpenVPN's status response. 199 | The code for which can be found in it's GitHub repo: https://github.com/tonyseek/openvpn-status 200 | 201 | Therefore when we fetch the status from the OpenVPN daemon, it'll be returned using their models. 202 | For more information see their docs: https://openvpn-status.readthedocs.io/en/latest/api.html 203 | 204 | Unlike the VPN state, the status is not stateful as it's output is highly likely to change between calls. 205 | Every time the status is requested, the management interface is queried for the latest data. 206 | 207 | A brief example: 208 | ```python 209 | >>> status = v.get_status() 210 | >>> status 211 | 212 | >>> status.client_list 213 | OrderedDict([('1.2.3.4:56789', )]) 214 | ``` 215 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.0.0 2 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | ignore_missing_imports = True 3 | no_incremental = True 4 | -------------------------------------------------------------------------------- /openvpn_api/__init__.py: -------------------------------------------------------------------------------- 1 | """Expose core parts to module namespace.""" 2 | from openvpn_api.vpn import VPN, VPNType 3 | from openvpn_api.util import errors 4 | -------------------------------------------------------------------------------- /openvpn_api/constants.py: -------------------------------------------------------------------------------- 1 | # Real-time notification message prefixes 2 | _NOTIFICATION_PREFIXES = ( 3 | "BYTECOUNT", 4 | "BYTECOUNT_CLI", 5 | "CLIENT", 6 | "ECHO", 7 | "FATAL", 8 | "HOLD", 9 | "INFO", 10 | "LOG", 11 | "NEED-OK", 12 | "NEED-STR", 13 | "PASSWORD", 14 | "STATE", 15 | "REMOTE", 16 | "PROXY", 17 | "RSA_SIGN", 18 | ) 19 | -------------------------------------------------------------------------------- /openvpn_api/models/__init__.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from ipaddress import IPv4Address, IPv6Address, ip_address 3 | from typing import Optional, Tuple, Union 4 | 5 | from openvpn_api import constants 6 | 7 | IPAddress = Union[IPv4Address, IPv6Address] 8 | 9 | 10 | class VPNModelBase(abc.ABC): 11 | """Base instance of all VPN data models with parsers.""" 12 | 13 | @classmethod 14 | @abc.abstractmethod 15 | def parse_raw(cls, raw: str): 16 | """The parsing method which takes the raw output from the OpenVPN management interface and returns an instance 17 | of the model. 18 | """ 19 | raise NotImplementedError 20 | 21 | @staticmethod 22 | def _parse_string(raw: Optional[str]) -> Optional[str]: 23 | """Return stripped raw unless string is empty raw, then return None. 24 | """ 25 | if raw is None: 26 | return None 27 | raw = str(raw).strip() 28 | if len(raw) == 0: 29 | return None 30 | return raw 31 | 32 | @classmethod 33 | def _parse_int(cls, raw: Optional[str]) -> Optional[int]: 34 | """Return int if raw is parsable unless raw is empty, then return None.""" 35 | raw = cls._parse_string(raw) 36 | if raw is None: 37 | return raw 38 | return int(raw) 39 | 40 | @classmethod 41 | def _parse_ipaddress(cls, raw: Optional[str]) -> Optional[IPAddress]: 42 | """Return IPAddress unless raw is empty, then return None.""" 43 | raw = cls._parse_string(raw) 44 | if raw is None: 45 | return None 46 | return ip_address(raw) 47 | 48 | @staticmethod 49 | def _parse_notification(line: str) -> Tuple[Optional[str], Optional[str]]: 50 | """Parse an OpenVPN real-time notification message into type and message.""" 51 | if line.startswith(">"): 52 | message = line[1:].split(":", 1) 53 | assert len(message) == 2, "Malformed notification" 54 | if message[0] in constants._NOTIFICATION_PREFIXES: 55 | return message[0], message[1] 56 | return None, None 57 | 58 | @classmethod 59 | def _is_notification(cls, line: str) -> bool: 60 | """Test if `line` is an OpenVPN notification message.""" 61 | notification, message = cls._parse_notification(line) 62 | return notification is not None and message is not None 63 | -------------------------------------------------------------------------------- /openvpn_api/models/state.py: -------------------------------------------------------------------------------- 1 | """ 2 | COMMAND -- state 3 | ---------------- 4 | 5 | Show the current OpenVPN state, show state history, or enable real-time notification of state changes. 6 | 7 | These are the OpenVPN states: 8 | 9 | CONNECTING -- OpenVPN's initial state. 10 | WAIT -- (Client only) Waiting for initial response from server. 11 | AUTH -- (Client only) Authenticating with server. 12 | GET_CONFIG -- (Client only) Downloading configuration options from server. 13 | ASSIGN_IP -- Assigning IP address to virtual network interface. 14 | ADD_ROUTES -- Adding routes to system. 15 | CONNECTED -- Initialization Sequence Completed. 16 | RECONNECTING -- A restart has occurred. 17 | EXITING -- A graceful exit is in progress. 18 | RESOLVE -- (Client only) DNS lookup 19 | TCP_CONNECT -- (Client only) Connecting to TCP server 20 | 21 | The output format consists of up to 9 comma-separated parameters: 22 | (a) the integer unix date/time, 23 | (b) the state name, 24 | (c) optional descriptive string (used mostly on RECONNECTING and EXITING to show the reason for the disconnect), 25 | (d) optional TUN/TAP local IPv4 address 26 | (e) optional address of remote server, 27 | (f) optional port of remote server, 28 | (g) optional local address, 29 | (h) optional local port, and 30 | (i) optional TUN/TAP local IPv6 address. 31 | """ 32 | 33 | 34 | import datetime 35 | from typing import Optional 36 | 37 | from openvpn_api.models import VPNModelBase, IPAddress 38 | from openvpn_api.util import errors 39 | 40 | 41 | class State(VPNModelBase): 42 | """OpenVPN daemon state model.""" 43 | 44 | def __init__( 45 | self, 46 | up_since: datetime.datetime = None, 47 | state_name: str = None, 48 | desc_string: str = None, 49 | local_virtual_v4_addr: IPAddress = None, 50 | remote_addr: IPAddress = None, 51 | remote_port: int = None, 52 | local_addr: IPAddress = None, 53 | local_port: int = None, 54 | local_virtual_v6_addr: str = None, 55 | ) -> None: 56 | # Datetime daemon started? 57 | self.up_since: Optional[datetime.datetime] = up_since 58 | # See states list in module docstring 59 | self.state_name: Optional[str] = state_name 60 | self.desc_string: Optional[str] = desc_string 61 | self.local_virtual_v4_addr: Optional[IPAddress] = local_virtual_v4_addr 62 | self.remote_addr: Optional[IPAddress] = remote_addr 63 | self.remote_port: Optional[int] = remote_port 64 | self.local_addr: Optional[IPAddress] = local_addr 65 | self.local_port: Optional[int] = local_port 66 | self.local_virtual_v6_addr: Optional[str] = local_virtual_v6_addr 67 | 68 | @property 69 | def mode(self) -> str: 70 | if self.remote_addr is None and self.local_addr is None: 71 | return "unknown" 72 | if self.remote_addr is None: 73 | return "server" 74 | return "client" 75 | 76 | @classmethod 77 | def parse_raw(cls, raw: str) -> "State": 78 | for line in raw.splitlines(): 79 | if cls._is_notification(line): 80 | continue 81 | if line.strip() == "END": 82 | break 83 | parts = line.split(",") 84 | assert len(parts) >= 8, "Received too few parts to parse state." 85 | # 0 - Unix timestamp of server start (UTC?) 86 | up_since = datetime.datetime.utcfromtimestamp(int(parts[0])) if parts[0] != "" else None 87 | # 1 - Connection state 88 | state_name = cls._parse_string(parts[1]) 89 | # 2 - Connection state description 90 | desc_string = cls._parse_string(parts[2]) 91 | # 3 - TUN/TAP local v4 address 92 | local_virtual_v4_addr = cls._parse_ipaddress(parts[3]) 93 | # 4 - Remote server address (client only) 94 | remote_addr = cls._parse_ipaddress(parts[4]) 95 | # 5 - Remote server port (client only) 96 | remote_port = cls._parse_int(parts[5]) 97 | # 6 - Local address 98 | local_addr = cls._parse_ipaddress(parts[6]) 99 | # 7 - Local port 100 | local_port = cls._parse_int(parts[7]) 101 | return cls( 102 | up_since=up_since, 103 | state_name=state_name, 104 | desc_string=desc_string, 105 | local_virtual_v4_addr=local_virtual_v4_addr, 106 | remote_addr=remote_addr, 107 | remote_port=remote_port, 108 | local_addr=local_addr, 109 | local_port=local_port, 110 | ) 111 | raise errors.ParseError("Did not get expected data from state.") 112 | 113 | def __repr__(self) -> str: 114 | return f"" 115 | -------------------------------------------------------------------------------- /openvpn_api/models/stats.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import Optional 3 | 4 | from openvpn_api.models import VPNModelBase 5 | from openvpn_api.util import errors 6 | 7 | 8 | class ServerStats(VPNModelBase): 9 | """OpenVPN server stats model.""" 10 | 11 | def __init__(self, client_count: int = None, bytes_in: int = None, bytes_out: int = None,) -> None: 12 | # Number of connected clients 13 | self.client_count: Optional[int] = client_count 14 | # Server bytes in 15 | self.bytes_in: Optional[int] = bytes_in 16 | # Server bytes out 17 | self.bytes_out: Optional[int] = bytes_out 18 | 19 | @classmethod 20 | def parse_raw(cls, raw: str) -> "ServerStats": 21 | """Parse raw `load-stats` response into an instance.""" 22 | for line in raw.splitlines(): 23 | if not line.startswith("SUCCESS"): 24 | continue 25 | match = re.search( 26 | r"SUCCESS: nclients=(?P\d+),bytesin=(?P\d+),bytesout=(?P\d+)", line 27 | ) 28 | if not match: 29 | raise errors.ParseError("Unable to parse stats from raw load-stats response.") 30 | return cls( 31 | client_count=int(match.group("nclients")), 32 | bytes_in=int(match.group("bytesin")), 33 | bytes_out=int(match.group("bytesout")), 34 | ) 35 | raise errors.ParseError("Did not get expected data from load-stats.") 36 | 37 | def __repr__(self) -> str: 38 | return f"" 39 | -------------------------------------------------------------------------------- /openvpn_api/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Add submodules for easy access 2 | from openvpn_api.util import logging 3 | -------------------------------------------------------------------------------- /openvpn_api/util/errors.py: -------------------------------------------------------------------------------- 1 | class VPNError(Exception): 2 | """Base exception for all other project exceptions.""" 3 | 4 | 5 | class NotConnectedError(VPNError): 6 | """Exception raised if not connected to the management interface and a command is called.""" 7 | 8 | 9 | class ConnectError(VPNError): 10 | """Exception raised on connection failure.""" 11 | 12 | 13 | class ParseError(VPNError): 14 | """Exception for all management interface parsing errors.""" 15 | -------------------------------------------------------------------------------- /openvpn_api/util/logging.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | 4 | 5 | def enable_debug_log(): 6 | """Log output of loggers to stdout for development.""" 7 | root = logging.getLogger() 8 | root.setLevel(logging.DEBUG) 9 | handler = logging.StreamHandler(sys.stdout) 10 | root.addHandler(handler) 11 | -------------------------------------------------------------------------------- /openvpn_api/vpn.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | import re 4 | import socket 5 | from enum import Enum 6 | from typing import Optional, Generator 7 | 8 | import openvpn_status 9 | from openvpn_status.models import Status 10 | 11 | from openvpn_api.models.state import State 12 | from openvpn_api.models.stats import ServerStats 13 | from openvpn_api.util import errors 14 | 15 | logger = logging.getLogger(__name__) 16 | 17 | 18 | class VPNType(str, Enum): 19 | IP = "ip" 20 | UNIX_SOCKET = "socket" 21 | 22 | 23 | class VPN: 24 | def __init__(self, host: str = None, port: int = None, unix_socket: str = None): 25 | if (unix_socket and host) or (unix_socket and port) or (not unix_socket and not host and not port): 26 | raise errors.VPNError("Must specify either socket or host and port") 27 | 28 | self._mgmt_socket: Optional[str] = unix_socket 29 | self._mgmt_host: Optional[str] = host 30 | self._mgmt_port: Optional[int] = port 31 | self._socket: Optional[socket.socket] = None 32 | 33 | # Release info cache 34 | self._release: Optional[str] = None 35 | 36 | @property 37 | def type(self) -> VPNType: 38 | """Get VPNType object for this VPN. 39 | """ 40 | if self._mgmt_socket: 41 | return VPNType.UNIX_SOCKET 42 | if self._mgmt_port and self._mgmt_host: 43 | return VPNType.IP 44 | raise ValueError("Invalid connection type") 45 | 46 | @property 47 | def mgmt_address(self) -> str: 48 | """Get address of management interface. 49 | """ 50 | if self.type == VPNType.IP: 51 | return f"{self._mgmt_host}:{self._mgmt_port}" 52 | else: 53 | return str(self._mgmt_socket) 54 | 55 | def connect(self) -> Optional[bool]: 56 | """Connect to management interface socket. 57 | """ 58 | try: 59 | if self.type == VPNType.IP: 60 | assert self._mgmt_host is not None and self._mgmt_port is not None 61 | self._socket = socket.create_connection((self._mgmt_host, self._mgmt_port), timeout=3) 62 | elif self.type == VPNType.UNIX_SOCKET: 63 | assert self._mgmt_socket is not None 64 | self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 65 | self._socket.connect(self._mgmt_socket) 66 | else: 67 | raise ValueError("Invalid connection type") 68 | 69 | resp = self._socket_recv() 70 | assert resp.startswith(">INFO"), "Did not get expected response from interface when opening socket." 71 | return True 72 | except (socket.timeout, socket.error) as e: 73 | raise errors.ConnectError(str(e)) from None 74 | 75 | def disconnect(self, _quit=True) -> None: 76 | """Disconnect from management interface socket. 77 | By default will issue the `quit` command to inform the management interface we are closing the connection 78 | """ 79 | if self._socket is not None: 80 | if _quit: 81 | self._socket_send("quit\n") 82 | self._socket.close() 83 | self._socket = None 84 | 85 | @property 86 | def is_connected(self) -> bool: 87 | """Determine if management interface socket is connected or not. 88 | """ 89 | return self._socket is not None 90 | 91 | @contextlib.contextmanager 92 | def connection(self) -> Generator: 93 | """Create context where management interface socket is open and close when done. 94 | """ 95 | self.connect() 96 | try: 97 | yield 98 | finally: 99 | self.disconnect() 100 | 101 | def _socket_send(self, data) -> None: 102 | """Convert data to bytes and send to socket. 103 | """ 104 | if self._socket is None: 105 | raise errors.NotConnectedError("You must be connected to the management interface to issue commands.") 106 | self._socket.send(bytes(data, "utf-8")) 107 | 108 | def _socket_recv(self) -> str: 109 | """Receive bytes from socket and convert to string. 110 | """ 111 | if self._socket is None: 112 | raise errors.NotConnectedError("You must be connected to the management interface to issue commands.") 113 | return self._socket.recv(4096).decode("utf-8") 114 | 115 | def send_command(self, cmd) -> str: 116 | """Send command to management interface and fetch response. 117 | """ 118 | logger.debug("Sending cmd: %r", cmd.strip()) 119 | self._socket_send(cmd + "\n") 120 | resp = self._socket_recv() 121 | if cmd.strip() not in ("load-stats", "signal SIGTERM"): 122 | while not (resp.strip().endswith("END") or resp.strip().startswith("SUCCESS:")): 123 | resp += self._socket_recv() 124 | logger.debug("Cmd response: %r", resp) 125 | return resp 126 | 127 | # Interface commands and parsing 128 | 129 | def _get_version(self) -> str: 130 | """Get OpenVPN version from socket. 131 | """ 132 | raw = self.send_command("version") 133 | for line in raw.splitlines(): 134 | if line.startswith("OpenVPN Version"): 135 | return line.replace("OpenVPN Version: ", "") 136 | raise errors.ParseError("Unable to get OpenVPN version, no matches found in socket response.") 137 | 138 | @property 139 | def release(self) -> str: 140 | """OpenVPN release string. 141 | """ 142 | if self._release is None: 143 | self._release = self._get_version() 144 | return self._release 145 | 146 | @property 147 | def version(self) -> Optional[str]: 148 | """OpenVPN version number. 149 | """ 150 | if self.release is None: 151 | return None 152 | match = re.search(r"OpenVPN (?P\d+.\d+.\d+)", self.release) 153 | if not match: 154 | raise errors.ParseError("Unable to parse version from release string.") 155 | return match.group("version") 156 | 157 | def get_state(self) -> State: 158 | """Get OpenVPN daemon state from socket. 159 | """ 160 | raw = self.send_command("state") 161 | return State.parse_raw(raw) 162 | 163 | def cache_data(self) -> None: 164 | """Cached some metadata about the connection. 165 | """ 166 | _ = self.release 167 | 168 | def clear_cache(self) -> None: 169 | """Clear cached state data about connection. 170 | """ 171 | self._release = None 172 | 173 | def send_sigterm(self) -> None: 174 | """Send a SIGTERM to the OpenVPN process. 175 | """ 176 | raw = self.send_command("signal SIGTERM") 177 | if raw.strip() != "SUCCESS: signal SIGTERM thrown": 178 | raise errors.ParseError("Did not get expected response after issuing SIGTERM.") 179 | self.disconnect(_quit=False) 180 | 181 | def get_stats(self) -> ServerStats: 182 | """Get latest VPN stats. 183 | """ 184 | raw = self.send_command("load-stats") 185 | return ServerStats.parse_raw(raw) 186 | 187 | def get_status(self) -> Status: 188 | """Get current status from VPN. 189 | 190 | Uses openvpn-status library to parse status output: 191 | https://pypi.org/project/openvpn-status/ 192 | """ 193 | raw = self.send_command("status 1") 194 | return openvpn_status.parse_status(raw) 195 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | line-length = 120 3 | target-version = ['py36'] 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import setuptools # type: ignore 3 | 4 | here = os.path.abspath(os.path.dirname(__file__)) 5 | 6 | # Get the long description from the README file 7 | with open(os.path.join(here, "README.md"), encoding="utf-8") as f: 8 | long_description = f.read() 9 | 10 | with open(os.path.join(here, "VERSION")) as f: 11 | version = f.read().strip() 12 | 13 | setuptools.setup( 14 | name="openvpn-api", 15 | version=version, 16 | description="A Python API for the OpenVPN management interface.", 17 | long_description=long_description, 18 | long_description_content_type="text/markdown", 19 | url="https://github.com/Jamie-/openvpn-api", 20 | author="Jamie Scott", 21 | author_email="contact@jami.org.uk", 22 | license="MIT", 23 | classifiers=[ 24 | "Development Status :: 3 - Alpha", 25 | "Intended Audience :: Developers", 26 | "Intended Audience :: System Administrators", 27 | "License :: OSI Approved :: MIT License", 28 | "Operating System :: OS Independent", 29 | "Programming Language :: Python :: 3.6", 30 | "Programming Language :: Python :: 3.7", 31 | "Programming Language :: Python :: 3.8", 32 | ], 33 | keywords="openvpn monitor management", 34 | packages=setuptools.find_packages(exclude=["tests"]), 35 | python_requires=">=3.6", 36 | install_requires=["openvpn_status",], 37 | extras_require={"dev": ["nose", "black",],}, 38 | project_urls={ 39 | "Source": "https://github.com/Jamie-/openvpn-api", 40 | "Bug Reports": "https://github.com/Jamie-/openvpn-api/issues", 41 | }, 42 | ) 43 | -------------------------------------------------------------------------------- /tests/test_model_base.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from ipaddress import IPv4Address, IPv6Address 3 | 4 | from openvpn_api.models import VPNModelBase 5 | 6 | 7 | class ModelStub(VPNModelBase): 8 | def parse_raw(cls, raw: str): 9 | return None 10 | 11 | 12 | class TestModelBase(unittest.TestCase): 13 | def test_parse_string(self): 14 | self.assertIsNone(ModelStub._parse_string(None)) 15 | self.assertIsNone(ModelStub._parse_string("")) 16 | self.assertEqual(ModelStub._parse_string("a"), "a") 17 | self.assertEqual(ModelStub._parse_string(" a "), "a") 18 | self.assertEqual(ModelStub._parse_string(1), "1") 19 | self.assertEqual(ModelStub._parse_string(False), "False") 20 | 21 | def test_parse_int(self): 22 | self.assertIsNone(ModelStub._parse_int(None)) 23 | self.assertEqual(ModelStub._parse_int(0), 0) 24 | self.assertEqual(ModelStub._parse_int(1), 1) 25 | with self.assertRaises(ValueError): 26 | ModelStub._parse_int("a") 27 | with self.assertRaises(ValueError): 28 | ModelStub._parse_int(False) 29 | 30 | def test_parse_ipaddress(self): 31 | self.assertIsNone(ModelStub._parse_ipaddress(None)) 32 | with self.subTest("IPv4"): 33 | self.assertEqual(IPv4Address("1.2.3.4"), ModelStub._parse_ipaddress("1.2.3.4")) 34 | self.assertEqual(IPv4Address("1.2.3.4"), ModelStub._parse_ipaddress(" 1.2.3.4 ")) 35 | self.assertEqual(IPv4Address("1.2.3.4"), ModelStub._parse_ipaddress("1.2.3.4\n")) 36 | with self.subTest("IPv6"): 37 | self.assertEqual(IPv6Address("::1:2:3:4"), ModelStub._parse_ipaddress("::1:2:3:4")) 38 | self.assertEqual(IPv6Address("::1:2:3:4"), ModelStub._parse_ipaddress(" ::1:2:3:4 ")) 39 | self.assertEqual(IPv6Address("::1:2:3:4"), ModelStub._parse_ipaddress("::1:2:3:4\n")) 40 | with self.assertRaises(ValueError): 41 | ModelStub._parse_ipaddress("asd") 42 | 43 | def test_parse_notification(self): 44 | self.assertEqual(("BYTECOUNT", "asd"), ModelStub._parse_notification(">BYTECOUNT:asd")) 45 | self.assertEqual(("CLIENT", "asd:qwe"), ModelStub._parse_notification(">CLIENT:asd:qwe")) 46 | with self.assertRaises(AssertionError): 47 | ModelStub._parse_notification(">INFO") 48 | self.assertEqual((None, None), ModelStub._parse_notification("asd")) 49 | 50 | def test_is_notification(self): 51 | self.assertTrue(ModelStub._is_notification(">BYTECOUNT:asd")) 52 | self.assertTrue(ModelStub._is_notification(">BYTECOUNT_CLI:asd")) 53 | self.assertTrue(ModelStub._is_notification(">CLIENT:asd")) 54 | self.assertTrue(ModelStub._is_notification(">ECHO:asd")) 55 | self.assertTrue(ModelStub._is_notification(">FATAL:asd")) 56 | self.assertTrue(ModelStub._is_notification(">HOLD:asd")) 57 | self.assertTrue(ModelStub._is_notification(">INFO:asd")) 58 | self.assertTrue(ModelStub._is_notification(">LOG:asd")) 59 | self.assertTrue(ModelStub._is_notification(">NEED-OK:asd")) 60 | self.assertTrue(ModelStub._is_notification(">NEED-STR:asd")) 61 | self.assertTrue(ModelStub._is_notification(">PASSWORD:asd")) 62 | self.assertTrue(ModelStub._is_notification(">STATE:asd")) 63 | self.assertTrue(ModelStub._is_notification(">REMOTE:asd")) 64 | self.assertTrue(ModelStub._is_notification(">PROXY:asd")) 65 | self.assertTrue(ModelStub._is_notification(">RSA_SIGN:asd")) 66 | self.assertFalse(ModelStub._is_notification(">XXX:asd")) 67 | -------------------------------------------------------------------------------- /tests/test_namespace.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class TestNamespace(unittest.TestCase): 5 | def test_import(self): 6 | from openvpn_api import VPN 7 | from openvpn_api import VPNType 8 | from openvpn_api import errors 9 | -------------------------------------------------------------------------------- /tests/test_server_stats_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from openvpn_api.models import stats 3 | from openvpn_api.util import errors 4 | 5 | 6 | class TestServerStats(unittest.TestCase): 7 | def test_repr(self): 8 | s = stats.ServerStats(client_count=15, bytes_in=23984723, bytes_out=24532) 9 | self.assertEqual("", repr(s)) 10 | 11 | def test_parse_raw(self): 12 | s = stats.ServerStats.parse_raw("SUCCESS: nclients=3,bytesin=129822996,bytesout=126946564") 13 | self.assertEqual(3, s.client_count) 14 | self.assertEqual(129822996, s.bytes_in) 15 | self.assertEqual(126946564, s.bytes_out) 16 | 17 | def test_parse_raw_empty(self): 18 | with self.assertRaises(errors.ParseError) as ctx: 19 | stats.ServerStats.parse_raw("") 20 | self.assertEqual("Did not get expected data from load-stats.", str(ctx.exception)) 21 | 22 | def test_parse_raw_invalid(self): 23 | with self.assertRaises(errors.ParseError) as ctx: 24 | stats.ServerStats.parse_raw("SUCCESS: nclients=3") 25 | self.assertEqual("Unable to parse stats from raw load-stats response.", str(ctx.exception)) 26 | 27 | def test_parse_raw_prefix(self): 28 | s = stats.ServerStats.parse_raw( 29 | """ 30 | >INFO: asd 31 | SUCCESS: nclients=3,bytesin=129822996,bytesout=126946564 32 | """ 33 | ) 34 | self.assertEqual(3, s.client_count) 35 | self.assertEqual(129822996, s.bytes_in) 36 | self.assertEqual(126946564, s.bytes_out) 37 | -------------------------------------------------------------------------------- /tests/test_state_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import datetime 3 | from ipaddress import IPv4Address 4 | 5 | import openvpn_api.models.state 6 | from openvpn_api.util import errors 7 | 8 | 9 | class TestState(unittest.TestCase): 10 | def test_repr(self): 11 | s = openvpn_api.models.state.State( 12 | datetime.datetime(2019, 6, 16, 21, 13, 21), 13 | "CONNECTED", 14 | "SUCCESS", 15 | IPv4Address("10.0.0.1"), 16 | None, # Should be None for server state 17 | None, # Should be None for server state 18 | IPv4Address("1.2.3.4"), 19 | 1194, 20 | None, 21 | ) 22 | self.assertEqual("", repr(s)) 23 | 24 | def test_init_none(self): 25 | s = openvpn_api.models.state.State(None, None, None, None, None, None, None, None, None) 26 | self.assertIsNone(s.up_since) 27 | self.assertIsNone(s.state_name) 28 | self.assertIsNone(s.desc_string) 29 | self.assertIsNone(s.local_virtual_v4_addr) 30 | self.assertIsNone(s.remote_addr) 31 | self.assertIsNone(s.remote_port) 32 | self.assertIsNone(s.local_addr) 33 | self.assertIsNone(s.local_port) 34 | self.assertIsNone(s.local_virtual_v6_addr) 35 | # Props 36 | self.assertEqual("unknown", s.mode) 37 | 38 | def test_parse_raw(self): 39 | s = openvpn_api.models.state.State.parse_raw("1560719601,CONNECTED,SUCCESS,10.0.0.1,,,1.2.3.4,1194\nEND") 40 | self.assertEqual(datetime.datetime(2019, 6, 16, 21, 13, 21), s.up_since) 41 | self.assertEqual("CONNECTED", s.state_name) 42 | self.assertEqual("SUCCESS", s.desc_string) 43 | self.assertEqual(IPv4Address("10.0.0.1"), s.local_virtual_v4_addr) 44 | self.assertEqual("10.0.0.1", str(s.local_virtual_v4_addr)) 45 | self.assertIsNone(s.remote_addr) 46 | self.assertIsNone(s.remote_port) 47 | self.assertEqual(IPv4Address("1.2.3.4"), s.local_addr) 48 | self.assertEqual("1.2.3.4", str(s.local_addr)) 49 | self.assertEqual(1194, s.local_port) 50 | self.assertIsNone(s.local_virtual_v6_addr) 51 | # Props 52 | self.assertEqual("server", s.mode) 53 | 54 | def test_parse_raw_empty(self): 55 | with self.assertRaises(errors.ParseError) as ctx: 56 | openvpn_api.models.state.State.parse_raw("") 57 | self.assertEqual("Did not get expected data from state.", str(ctx.exception)) 58 | -------------------------------------------------------------------------------- /tests/test_vpn_model.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import socket 3 | from unittest.mock import patch, PropertyMock, ANY, MagicMock 4 | import openvpn_status 5 | from openvpn_api.util import errors 6 | from openvpn_api.vpn import VPN, VPNType 7 | 8 | 9 | def gen_mock_values(values): 10 | """Generator to return the next value in a list of values on every call. 11 | 12 | >>> vals = gen_mock_values([1, 2, 3]) 13 | >>> mocked_func.side_effect = lambda: next(vals) 14 | >>> mocked_func() 15 | 1 16 | >>> mocked_func() 17 | 2 18 | """ 19 | for value in values: 20 | yield value 21 | 22 | 23 | class TestVPNModel(unittest.TestCase): 24 | """Test the config file parser monitor.util.config_parser.ConfigParser 25 | """ 26 | 27 | def test_host_port_socket(self): 28 | with self.assertRaises(errors.VPNError) as ctx: 29 | VPN(host="localhost", port=1234, unix_socket="file.sock") 30 | self.assertEqual("Must specify either socket or host and port", str(ctx.exception)) 31 | 32 | def test_host_port(self): 33 | vpn = VPN(host="localhost", port=1234) 34 | self.assertEqual(vpn._mgmt_host, "localhost") 35 | self.assertEqual(vpn._mgmt_port, 1234) 36 | self.assertEqual(vpn.type, VPNType.IP) 37 | self.assertEqual(vpn.mgmt_address, "localhost:1234") 38 | 39 | def test_socket(self): 40 | vpn = VPN(unix_socket="file.sock") 41 | self.assertEqual(vpn._mgmt_socket, "file.sock") 42 | self.assertEqual(vpn.type, VPNType.UNIX_SOCKET) 43 | self.assertEqual(vpn.mgmt_address, "file.sock") 44 | 45 | def test_initialisation(self): 46 | vpn = VPN(unix_socket="file.sock") 47 | self.assertIsNone(vpn._release) 48 | self.assertIsNone(vpn._socket) 49 | 50 | @patch("openvpn_api.vpn.socket.create_connection") 51 | def test_connect_ip_failure(self, mock_create_connection): 52 | vpn = VPN(host="localhost", port=1234) 53 | mock_create_connection.side_effect = socket.error() 54 | with self.assertRaises(errors.ConnectError): 55 | vpn.connect() 56 | mock_create_connection.side_effect = socket.timeout() 57 | with self.assertRaises(errors.ConnectError): 58 | vpn.connect() 59 | 60 | @patch("openvpn_api.vpn.VPN.connect") 61 | @patch("openvpn_api.vpn.VPN.disconnect") 62 | def test_connection_manager(self, mock_disconnect, mock_connect): 63 | vpn = VPN(host="localhost", port=1234) 64 | with vpn.connection(): 65 | mock_connect.assert_called_once() 66 | mock_disconnect.assert_not_called() 67 | mock_connect.reset_mock() 68 | mock_connect.assert_not_called() 69 | mock_disconnect.assert_called_once() 70 | 71 | def test_send_command_disconnected(self): 72 | vpn = VPN(host="localhost", port=1234) 73 | with self.assertRaises(errors.NotConnectedError): 74 | vpn.send_command("asd") 75 | 76 | @patch("openvpn_api.vpn.VPN._socket_recv") 77 | @patch("openvpn_api.vpn.VPN._socket_send") 78 | @patch("openvpn_api.vpn.socket.create_connection") 79 | def test_send_command(self, mock_create_connection, mock_socket_send, mock_socket_recv): 80 | vpn = VPN(host="localhost", port=1234) 81 | vpn.connect() 82 | mock_create_connection.assert_called_once_with(("localhost", 1234), timeout=ANY) 83 | mock_socket_recv.assert_called_once() 84 | mock_socket_recv.reset_mock() 85 | vals = gen_mock_values(["asd\n", "END\n"]) 86 | mock_socket_recv.side_effect = lambda: next(vals) 87 | a = vpn.send_command("help") 88 | mock_socket_send.assert_called_once_with("help\n") 89 | self.assertEqual(2, mock_socket_recv.call_count) 90 | self.assertEqual(a, "asd\nEND\n") 91 | 92 | @patch("openvpn_api.vpn.VPN._socket_recv") 93 | @patch("openvpn_api.vpn.VPN._socket_send") 94 | @patch("openvpn_api.vpn.socket.create_connection") 95 | def test_send_command_kill(self, mock_create_connection, mock_socket_send, mock_socket_recv): 96 | # This test just makes sure we don't infinitely loop reading from socket waiting for END 97 | # Needs rewriting once we add methods for killing clients. 98 | # Example output from management interface: 99 | # client-kill 1 100 | # SUCCESS: client-kill command succeeded 101 | # kill 1.2.3.4:12345 102 | # SUCCESS: 1 client(s) at address 1.2.3.4:12345 killed 103 | vpn = VPN(host="localhost", port=1234) 104 | vpn.connect() 105 | mock_create_connection.assert_called_once_with(("localhost", 1234), timeout=ANY) 106 | mock_socket_recv.assert_called_once() 107 | mock_socket_recv.reset_mock() 108 | mock_socket_recv.return_value = "SUCCESS: 1 client(s) at address 1.2.3.4:12345 killed" 109 | vpn.send_command("kill 1.2.3.4:12345") 110 | mock_socket_send.assert_called_once_with("kill 1.2.3.4:12345\n") 111 | mock_socket_recv.assert_called_once() 112 | mock_socket_send.reset_mock() 113 | mock_socket_recv.reset_mock() 114 | mock_socket_recv.return_value = "SUCCESS: client-kill command succeeded" 115 | vpn.send_command("client-kill 1") 116 | mock_socket_send.assert_called_once_with("client-kill 1\n") 117 | mock_socket_recv.assert_called_once() 118 | 119 | @patch("openvpn_api.vpn.VPN._socket_recv") 120 | @patch("openvpn_api.vpn.VPN._socket_send") 121 | @patch("openvpn_api.vpn.socket.create_connection") 122 | def test_send_sigterm(self, mock_create_connection, mock_socket_send, mock_socket_recv): 123 | vpn = VPN(host="localhost", port=1234) 124 | vpn.connect() 125 | mock_create_connection.assert_called_once_with(("localhost", 1234), timeout=ANY) 126 | mock_socket_recv.assert_called_once() 127 | mock_socket_recv.reset_mock() 128 | mock_socket_recv.return_value = "SUCCESS: signal SIGTERM thrown" 129 | vpn.send_sigterm() 130 | mock_socket_send.assert_called_once_with("signal SIGTERM\n") 131 | mock_socket_recv.assert_called_once() 132 | 133 | @patch("openvpn_api.vpn.VPN.send_command") 134 | def test__get_version(self, mock_send_command): 135 | vpn = VPN(host="localhost", port=1234) 136 | mock_send_command.return_value = """ 137 | OpenVPN Version: OpenVPN 2.4.4 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Sep 5 2018 138 | Management Version: 1 139 | END 140 | """ 141 | self.assertEqual( 142 | vpn._get_version(), 143 | "OpenVPN 2.4.4 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Sep 5 2018", 144 | ) 145 | mock_send_command.assert_called_once_with("version") 146 | mock_send_command.reset_mock() 147 | mock_send_command.return_value = "" 148 | with self.assertRaises(errors.ParseError) as ctx: 149 | vpn._get_version() 150 | self.assertEqual("Unable to get OpenVPN version, no matches found in socket response.", str(ctx.exception)) 151 | mock_send_command.assert_called_once_with("version") 152 | mock_send_command.reset_mock() 153 | mock_send_command.return_value = """ 154 | Management Version: 1 155 | END 156 | """ 157 | with self.assertRaises(errors.ParseError) as ctx: 158 | vpn._get_version() 159 | self.assertEqual("Unable to get OpenVPN version, no matches found in socket response.", str(ctx.exception)) 160 | mock_send_command.assert_called_once_with("version") 161 | mock_send_command.reset_mock() 162 | 163 | @patch("openvpn_api.vpn.VPN._get_version") 164 | def test_release(self, mock_get_version): 165 | vpn = VPN(host="localhost", port=1234) 166 | self.assertIsNone(vpn._release) 167 | release_string = "OpenVPN 2.4.4 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Sep 5 2018" 168 | mock_get_version.return_value = release_string 169 | self.assertEqual(vpn.release, release_string) 170 | self.assertEqual(vpn._release, release_string) 171 | mock_get_version.assert_called_once_with() 172 | mock_get_version.reset_mock() 173 | vpn._release = "asd" 174 | self.assertEqual(vpn.release, "asd") 175 | mock_get_version.assert_not_called() 176 | 177 | @patch("openvpn_api.vpn.VPN._get_version") 178 | def test_version(self, mock_get_version): 179 | vpn = VPN(host="localhost", port=1234) 180 | vpn._release = "OpenVPN 2.4.4 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Sep 5 2018" 181 | self.assertEqual(vpn.version, "2.4.4") 182 | vpn._release = "OpenVPN 1.2.3 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Sep 5 2018" 183 | self.assertEqual(vpn.version, "1.2.3") 184 | vpn._release = "OpenVPN 11.22.33 x86_64-pc-linux-gnu [SSL (OpenSSL)] [LZO] [LZ4] [EPOLL] [PKCS11] [MH/PKTINFO] [AEAD] built on Sep 5 2018" 185 | self.assertEqual(vpn.version, "11.22.33") 186 | vpn._release = None 187 | mock_get_version.assert_not_called() # Check mock hasn't been triggered up to this point 188 | mock_get_version.return_value = None 189 | self.assertIsNone(vpn.version) 190 | mock_get_version.assert_called_once() 191 | mock_get_version.reset_mock() 192 | vpn._release = "asd" 193 | with self.assertRaises(errors.ParseError) as ctx: 194 | vpn.version() 195 | self.assertEqual("Unable to parse version from release string.", str(ctx.exception)) 196 | mock_get_version.assert_not_called() 197 | 198 | @patch("openvpn_api.vpn.VPN.send_command") 199 | @patch("openvpn_api.models.state.State.parse_raw") 200 | def test_get_state(self, mock_parse_raw, mock_send_command): 201 | vpn = VPN(host="localhost", port=1234) 202 | state = vpn.get_state() 203 | mock_send_command.assert_called_once_with("state") 204 | mock_parse_raw.assert_called_once() 205 | self.assertIsNotNone(state) 206 | 207 | @patch("openvpn_api.vpn.VPN.release", new_callable=PropertyMock) 208 | def test_cache(self, release_mock): 209 | """Test caching VPN metadata works and clears correctly. 210 | """ 211 | vpn = VPN(host="localhost", port=1234) 212 | vpn.cache_data() 213 | release_mock.assert_called_once() 214 | vpn._release = "asd" 215 | vpn.clear_cache() 216 | self.assertIsNone(vpn._release) 217 | 218 | @patch("openvpn_api.vpn.VPN.send_command") 219 | @patch("openvpn_api.models.stats.ServerStats.parse_raw") 220 | def test_get_stats(self, mock_parse_raw, mock_send_command): 221 | vpn = VPN(host="localhost", port=1234) 222 | stats = vpn.get_stats() 223 | mock_send_command.assert_called_once_with("load-stats") 224 | mock_parse_raw.assert_called_once() 225 | self.assertIsNotNone(stats) 226 | 227 | @patch("openvpn_api.vpn.VPN.send_command") 228 | def test_get_status(self, mock): 229 | vpn = VPN(host="localhost", port=1234) 230 | mock.return_value = """OpenVPN CLIENT LIST 231 | Updated,Thu Jul 18 20:47:42 2019 232 | Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since 233 | testclient,1.2.3.4:12345,123456789,123456789,Tue Jun 11 21:22:02 2019 234 | ROUTING TABLE 235 | Virtual Address,Common Name,Real Address,Last Ref 236 | 10.0.0.2,testclient,1.2.3.4:12345,Wed Jun 12 21:55:04 2019 237 | GLOBAL STATS 238 | Max bcast/mcast queue length,2 239 | END 240 | """ 241 | status = vpn.get_status() 242 | mock.assert_called_once() 243 | self.assertIsInstance(status, openvpn_status.models.Status) 244 | self.assertEqual(len(status.client_list), 1) 245 | self.assertEqual(list(status.client_list.keys()), ["1.2.3.4:12345"]) 246 | --------------------------------------------------------------------------------