openvpn-status-reader 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. openvpn_status_reader-0.3.0/AUTHORS.rst +1 -0
  2. openvpn_status_reader-0.3.0/LICENSE +20 -0
  3. openvpn_status_reader-0.3.0/PKG-INFO +149 -0
  4. openvpn_status_reader-0.3.0/README.rst +104 -0
  5. openvpn_status_reader-0.3.0/openvpn_status/__init__.py +5 -0
  6. openvpn_status_reader-0.3.0/openvpn_status/descriptors.py +51 -0
  7. openvpn_status_reader-0.3.0/openvpn_status/models.py +44 -0
  8. openvpn_status_reader-0.3.0/openvpn_status/parser.py +144 -0
  9. openvpn_status_reader-0.3.0/openvpn_status/shortcuts.py +16 -0
  10. openvpn_status_reader-0.3.0/openvpn_status/utils.py +82 -0
  11. openvpn_status_reader-0.3.0/openvpn_status/version.py +16 -0
  12. openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/PKG-INFO +149 -0
  13. openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/SOURCES.txt +24 -0
  14. openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/dependency_links.txt +1 -0
  15. openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/not-zip-safe +1 -0
  16. openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/requires.txt +2 -0
  17. openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/top_level.txt +1 -0
  18. openvpn_status_reader-0.3.0/pyproject.toml +16 -0
  19. openvpn_status_reader-0.3.0/setup.cfg +14 -0
  20. openvpn_status_reader-0.3.0/setup.py +51 -0
  21. openvpn_status_reader-0.3.0/tests/test_descriptors.py +38 -0
  22. openvpn_status_reader-0.3.0/tests/test_models.py +105 -0
  23. openvpn_status_reader-0.3.0/tests/test_package_meta.py +31 -0
  24. openvpn_status_reader-0.3.0/tests/test_parser.py +141 -0
  25. openvpn_status_reader-0.3.0/tests/test_utils.py +75 -0
@@ -0,0 +1 @@
1
+ - Jiangge Zhang <tonyseek@gmail.com>
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+ Copyright (c) Jiangge Zhang
3
+
4
+ Permission is hereby granted, free of charge, to any person obtaining a copy
5
+ of this software and associated documentation files (the "Software"), to deal
6
+ in the Software without restriction, including without limitation the rights
7
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
8
+ copies of the Software, and to permit persons to whom the Software is
9
+ furnished to do so, subject to the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be included in all
12
+ copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
17
+ IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
18
+ DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
19
+ OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
20
+ OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: openvpn-status-reader
3
+ Version: 0.3.0
4
+ Summary: Parse OpenVPN status logs in Python
5
+ Home-page: https://github.com/openwisp/openvpn-status-reader
6
+ Author: OpenWISP
7
+ Author-email: support@openwisp.org
8
+ License: MIT
9
+ Keywords: openvpn,status,log
10
+ Platform: Any
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: Implementation :: CPython
22
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
23
+ Classifier: Topic :: Internet :: Log Analysis
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: System :: Logging
26
+ Classifier: Topic :: Text Processing
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.11
29
+ License-File: LICENSE
30
+ License-File: AUTHORS.rst
31
+ Requires-Dist: humanize<5,>=4.16
32
+ Requires-Dist: netaddr<2,>=1.3
33
+ Dynamic: author
34
+ Dynamic: author-email
35
+ Dynamic: classifier
36
+ Dynamic: description
37
+ Dynamic: home-page
38
+ Dynamic: keywords
39
+ Dynamic: license
40
+ Dynamic: license-file
41
+ Dynamic: platform
42
+ Dynamic: requires-dist
43
+ Dynamic: requires-python
44
+ Dynamic: summary
45
+
46
+ OpenVPN Status Reader
47
+ =====================
48
+
49
+ .. image:: https://github.com/openwisp/openvpn-status-reader/actions/workflows/ci.yml/badge.svg?branch=master
50
+ :target: https://github.com/openwisp/openvpn-status-reader/actions/workflows/ci.yml
51
+ :alt: CI build status
52
+
53
+ .. image:: https://coveralls.io/repos/openwisp/openvpn-status-reader/badge.svg
54
+ :target: https://coveralls.io/r/openwisp/openvpn-status-reader
55
+
56
+ .. image:: https://img.shields.io/librariesio/release/github/openwisp/openvpn-status-reader
57
+ :target: https://libraries.io/github/openwisp/openvpn-status-reader#repository_dependencies
58
+ :alt: Dependency monitoring
59
+
60
+ .. image:: https://img.shields.io/gitter/room/nwjs/nw.js.svg?style=flat-square
61
+ :target: https://gitter.im/openwisp/general
62
+
63
+ .. image:: https://badge.fury.io/py/openvpn-status-reader.svg
64
+ :target: https://badge.fury.io/py/openvpn-status-reader
65
+
66
+ .. image:: https://pepy.tech/badge/openvpn-status-reader
67
+ :target: https://pepy.tech/project/openvpn-status-reader
68
+ :alt: downloads
69
+
70
+ .. image:: https://img.shields.io/badge/code%20style-black-000000.svg
71
+ :target: https://pypi.org/project/black/
72
+ :alt: code style: black
73
+
74
+ .. summary-begin
75
+
76
+ **openvpn-status-reader** is an OpenWISP-maintained Python library for
77
+ parsing OpenVPN status-version-1 logs into Python data structures. It
78
+ supports both legacy and current numeric OpenVPN timestamps.
79
+
80
+ .. summary-end
81
+
82
+ This project is a fork of the original openvpn-status_ repository. It
83
+ retains the ``openvpn_status`` import namespace for compatibility.
84
+
85
+ It supports Python 3.11 and newer.
86
+
87
+ Installation
88
+ ------------
89
+
90
+ .. code-block:: bash
91
+
92
+ pip install openvpn-status-reader
93
+
94
+ Don't forget to put it in ``setup.py`` / ``requirements.txt``.
95
+
96
+ Getting Started
97
+ ---------------
98
+
99
+ You could configure your OpenVPN server to log for client status. In usual
100
+ it could be achieved by adding ``status /path/to/openvpn-status.log`` line
101
+ to ``/etc/openvpn/openvpn.conf``. For example:
102
+
103
+ ::
104
+
105
+ proto udp
106
+ port 1194
107
+ dev tun0
108
+ status /var/run/openvpn-status.log
109
+
110
+ Once OpenVPN server running, the log file will be created and written. It
111
+ looks like:
112
+
113
+ ::
114
+
115
+ OpenVPN CLIENT LIST
116
+ Updated,Thu Jun 18 08:12:15 2015
117
+ Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
118
+ foo@example.com,10.10.10.10:49502,334948,1973012,Thu Jun 18 04:23:03 2015
119
+ bar@example.com,10.10.10.10:64169,1817262,28981224,Thu Jun 18 04:08:39 2015
120
+ ROUTING TABLE
121
+ Virtual Address,Common Name,Real Address,Last Ref
122
+ 192.168.255.134,foo@example.com,10.10.10.10:49502,Thu Jun 18 08:12:09 2015
123
+ 192.168.255.126,bar@example.com,10.10.10.10:64169,Thu Jun 18 08:11:55 2015
124
+ GLOBAL STATS
125
+ Max bcast/mcast queue length,0
126
+ END
127
+
128
+ Now we could parse log file with this library:
129
+
130
+ .. code-block:: python
131
+
132
+ from openvpn_status import parse_status
133
+
134
+ with open("/var/run/openvpn-status.log") as logfile:
135
+ status = parse_status(logfile.read())
136
+
137
+ print(status.updated_at) # datetime.datetime(2015, 6, 18, 8, 12, 15)
138
+
139
+ foo_client = status.client_list["169.254.0.1"]
140
+ print(foo_client.common_name) # foo@example.com
141
+ print(foo_client.bytes_received) # 334.9 kB
142
+ print(foo_client.bytes_sent) # 2.0 MB
143
+ print(int(foo_client.bytes_sent)) # 2097152
144
+
145
+ More details are in the `API reference`_.
146
+
147
+ .. _api reference: https://openvpn-status-reader.readthedocs.io/en/latest/api.html
148
+
149
+ .. _openvpn-status: https://github.com/sgares/openvpn-status
@@ -0,0 +1,104 @@
1
+ OpenVPN Status Reader
2
+ =====================
3
+
4
+ .. image:: https://github.com/openwisp/openvpn-status-reader/actions/workflows/ci.yml/badge.svg?branch=master
5
+ :target: https://github.com/openwisp/openvpn-status-reader/actions/workflows/ci.yml
6
+ :alt: CI build status
7
+
8
+ .. image:: https://coveralls.io/repos/openwisp/openvpn-status-reader/badge.svg
9
+ :target: https://coveralls.io/r/openwisp/openvpn-status-reader
10
+
11
+ .. image:: https://img.shields.io/librariesio/release/github/openwisp/openvpn-status-reader
12
+ :target: https://libraries.io/github/openwisp/openvpn-status-reader#repository_dependencies
13
+ :alt: Dependency monitoring
14
+
15
+ .. image:: https://img.shields.io/gitter/room/nwjs/nw.js.svg?style=flat-square
16
+ :target: https://gitter.im/openwisp/general
17
+
18
+ .. image:: https://badge.fury.io/py/openvpn-status-reader.svg
19
+ :target: https://badge.fury.io/py/openvpn-status-reader
20
+
21
+ .. image:: https://pepy.tech/badge/openvpn-status-reader
22
+ :target: https://pepy.tech/project/openvpn-status-reader
23
+ :alt: downloads
24
+
25
+ .. image:: https://img.shields.io/badge/code%20style-black-000000.svg
26
+ :target: https://pypi.org/project/black/
27
+ :alt: code style: black
28
+
29
+ .. summary-begin
30
+
31
+ **openvpn-status-reader** is an OpenWISP-maintained Python library for
32
+ parsing OpenVPN status-version-1 logs into Python data structures. It
33
+ supports both legacy and current numeric OpenVPN timestamps.
34
+
35
+ .. summary-end
36
+
37
+ This project is a fork of the original openvpn-status_ repository. It
38
+ retains the ``openvpn_status`` import namespace for compatibility.
39
+
40
+ It supports Python 3.11 and newer.
41
+
42
+ Installation
43
+ ------------
44
+
45
+ .. code-block:: bash
46
+
47
+ pip install openvpn-status-reader
48
+
49
+ Don't forget to put it in ``setup.py`` / ``requirements.txt``.
50
+
51
+ Getting Started
52
+ ---------------
53
+
54
+ You could configure your OpenVPN server to log for client status. In usual
55
+ it could be achieved by adding ``status /path/to/openvpn-status.log`` line
56
+ to ``/etc/openvpn/openvpn.conf``. For example:
57
+
58
+ ::
59
+
60
+ proto udp
61
+ port 1194
62
+ dev tun0
63
+ status /var/run/openvpn-status.log
64
+
65
+ Once OpenVPN server running, the log file will be created and written. It
66
+ looks like:
67
+
68
+ ::
69
+
70
+ OpenVPN CLIENT LIST
71
+ Updated,Thu Jun 18 08:12:15 2015
72
+ Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
73
+ foo@example.com,10.10.10.10:49502,334948,1973012,Thu Jun 18 04:23:03 2015
74
+ bar@example.com,10.10.10.10:64169,1817262,28981224,Thu Jun 18 04:08:39 2015
75
+ ROUTING TABLE
76
+ Virtual Address,Common Name,Real Address,Last Ref
77
+ 192.168.255.134,foo@example.com,10.10.10.10:49502,Thu Jun 18 08:12:09 2015
78
+ 192.168.255.126,bar@example.com,10.10.10.10:64169,Thu Jun 18 08:11:55 2015
79
+ GLOBAL STATS
80
+ Max bcast/mcast queue length,0
81
+ END
82
+
83
+ Now we could parse log file with this library:
84
+
85
+ .. code-block:: python
86
+
87
+ from openvpn_status import parse_status
88
+
89
+ with open("/var/run/openvpn-status.log") as logfile:
90
+ status = parse_status(logfile.read())
91
+
92
+ print(status.updated_at) # datetime.datetime(2015, 6, 18, 8, 12, 15)
93
+
94
+ foo_client = status.client_list["169.254.0.1"]
95
+ print(foo_client.common_name) # foo@example.com
96
+ print(foo_client.bytes_received) # 334.9 kB
97
+ print(foo_client.bytes_sent) # 2.0 MB
98
+ print(int(foo_client.bytes_sent)) # 2097152
99
+
100
+ More details are in the `API reference`_.
101
+
102
+ .. _api reference: https://openvpn-status-reader.readthedocs.io/en/latest/api.html
103
+
104
+ .. _openvpn-status: https://github.com/sgares/openvpn-status
@@ -0,0 +1,5 @@
1
+ from .parser import ParsingError
2
+ from .shortcuts import parse_status
3
+ from .version import __version__
4
+
5
+ __all__ = ["parse_status", "ParsingError", "__version__"]
@@ -0,0 +1,51 @@
1
+ class LabelProperty:
2
+ """The property with label name."""
3
+
4
+ def __init__(self, label, default=None, input_type=None):
5
+ self.label = label
6
+ self.default = default
7
+ self.input_type = input_type
8
+ self.__name__ = None
9
+
10
+ def __get__(self, instance, owner):
11
+ if instance is None:
12
+ return self
13
+ if self.__name__ in instance.__dict__:
14
+ return instance.__dict__[self.__name__]
15
+ elif self.default is not None:
16
+ value = self.default()
17
+ instance.__dict__[self.__name__] = value
18
+ return value
19
+ else:
20
+ names = (owner.__name__, self.__name__)
21
+ raise AttributeError("%r object has no attribute %r" % names)
22
+
23
+ def __set__(self, instance, value):
24
+ if self.input_type is not None:
25
+ try:
26
+ value = self.input_type(value)
27
+ except ValueError as e:
28
+ raise AssignmentValueError(e)
29
+ instance.__dict__[self.__name__] = value
30
+
31
+
32
+ _missing = object()
33
+
34
+
35
+ def name_descriptors(cls):
36
+ for name, value in iter_descriptors(cls):
37
+ if getattr(value, "__name__", _missing) is None:
38
+ value.__name__ = name
39
+ return cls
40
+
41
+
42
+ def iter_descriptors(cls):
43
+ for name, value in cls.__dict__.items():
44
+ if name.startswith("__"):
45
+ continue
46
+ if getattr(value, "__get__", None) or getattr(value, "__set__", None):
47
+ yield name, value
48
+
49
+
50
+ class AssignmentValueError(ValueError):
51
+ pass
@@ -0,0 +1,44 @@
1
+ from collections import OrderedDict
2
+
3
+ from .descriptors import LabelProperty, name_descriptors
4
+ from .utils import parse_filesize, parse_peer, parse_time, parse_vaddr
5
+
6
+
7
+ @name_descriptors
8
+ class Status:
9
+ """The OpenVPN status model."""
10
+
11
+ client_list = LabelProperty("OpenVPN CLIENT LIST", OrderedDict)
12
+ routing_table = LabelProperty("ROUTING TABLE", OrderedDict)
13
+ global_stats = LabelProperty("GLOBAL STATS")
14
+ updated_at = LabelProperty("Updated", input_type=parse_time)
15
+
16
+
17
+ @name_descriptors
18
+ class Client:
19
+ """The OpenVPN client model."""
20
+
21
+ common_name = LabelProperty("Common Name")
22
+ real_address = LabelProperty("Real Address", input_type=parse_peer)
23
+ bytes_received = LabelProperty("Bytes Received", input_type=parse_filesize)
24
+ bytes_sent = LabelProperty("Bytes Sent", input_type=parse_filesize)
25
+ connected_since = LabelProperty("Connected Since", input_type=parse_time)
26
+
27
+
28
+ @name_descriptors
29
+ class Routing:
30
+ """The OpenVPN routing model."""
31
+
32
+ virtual_address = LabelProperty("Virtual Address", input_type=parse_vaddr)
33
+ common_name = LabelProperty("Common Name")
34
+ real_address = LabelProperty("Real Address", input_type=parse_peer)
35
+ last_ref = LabelProperty("Last Ref", input_type=parse_time)
36
+
37
+
38
+ @name_descriptors
39
+ class GlobalStats:
40
+ """The OpenVPN global stats model."""
41
+
42
+ max_bcast_mcast_queue_len = LabelProperty(
43
+ "Max bcast/mcast queue length", input_type=int
44
+ )
@@ -0,0 +1,144 @@
1
+ from collections.abc import Iterator
2
+
3
+ from .descriptors import AssignmentValueError, iter_descriptors
4
+ from .models import Client, GlobalStats, Routing, Status
5
+
6
+
7
+ class LogParser(Iterator):
8
+ """The parser for parsing OpenVPN status log.
9
+
10
+ This kind of parser is stateful. So the :meth:`LogParser.parse` could be
11
+ called once in the same instance of parser.
12
+ """
13
+
14
+ list_separator = ","
15
+ line_separator = "\n"
16
+ terminator = "END"
17
+
18
+ def __init__(self, lines):
19
+ self.lines = iter(lines)
20
+ self._last_line = None
21
+ self._rollback = False
22
+
23
+ def __next__(self):
24
+ if self._rollback:
25
+ self._rollback = False
26
+ return self._last_line
27
+ while True:
28
+ line = next(self.lines).strip()
29
+ if line:
30
+ self._last_line = line
31
+ return line
32
+
33
+ @classmethod
34
+ def fromstring(cls, content):
35
+ """Creates a parser from content of log.
36
+
37
+ :param str content: The log content.
38
+ :return: The parser instance.
39
+ """
40
+ return cls(content.strip().split(cls.line_separator))
41
+
42
+ def rollback(self):
43
+ self._rollback = True
44
+
45
+ def expect_line(self, content):
46
+ try:
47
+ line = next(self)
48
+ except StopIteration:
49
+ raise ParsingError("expected %r but got end of input" % content)
50
+ if line != content:
51
+ raise ParsingError("expected %r but got %r" % (content, line))
52
+
53
+ def expect_list(self):
54
+ try:
55
+ line = next(self)
56
+ except StopIteration:
57
+ raise ParsingError("expected list but got end of input")
58
+ splited = line.split(self.list_separator)
59
+ if len(splited) == 1:
60
+ raise ParsingError("expected list but got %r" % line)
61
+ return splited
62
+
63
+ def expect_tuple(self, name):
64
+ try:
65
+ line = next(self)
66
+ except StopIteration:
67
+ raise ParsingError("expected 2-tuple but got end of input")
68
+ splited = line.split(self.list_separator)
69
+ if len(splited) != 2:
70
+ raise ParsingError("expected 2-tuple but got %r" % line)
71
+ if splited[0] != name:
72
+ raise ParsingError("expected 2-tuple starting with %r" % name)
73
+ return splited[1]
74
+
75
+ def parse(self):
76
+ """Parses the status log.
77
+
78
+ :raises ParsingError: if syntax error found in the log.
79
+ :return: The :class:`.models.Status` with filled data.
80
+ """
81
+ try:
82
+ return self._parse()
83
+ except AssignmentValueError as e:
84
+ msg = str(e).encode("ascii", "backslashreplace").decode("ascii")
85
+ raise ParsingError("expected valid format: %s" % msg)
86
+
87
+ def _parse(self):
88
+ status = Status()
89
+ self.expect_line(Status.client_list.label)
90
+
91
+ status.updated_at = self.expect_tuple(Status.updated_at.label)
92
+ status.client_list.update(
93
+ {
94
+ str(c.real_address): c
95
+ for c in self._parse_fields(Client, Status.routing_table.label)
96
+ }
97
+ )
98
+ status.routing_table.update(
99
+ {
100
+ str(r.virtual_address): r
101
+ for r in self._parse_fields(Routing, Status.global_stats.label)
102
+ }
103
+ )
104
+ status.global_stats = GlobalStats()
105
+ status.global_stats.max_bcast_mcast_queue_len = self.expect_tuple(
106
+ GlobalStats.max_bcast_mcast_queue_len.label
107
+ )
108
+
109
+ self.expect_line(self.terminator)
110
+ return status
111
+
112
+ def _parse_fields(self, cls, next_line):
113
+ labels = self.expect_list()
114
+ descriptors = iter_descriptors(cls)
115
+ label_to_name = {descriptor.label: name for name, descriptor in descriptors}
116
+ if len(labels) != len(label_to_name) or set(labels) != set(label_to_name):
117
+ raise ParsingError("invalid fields for %s" % cls.__name__)
118
+ index_to_name = {
119
+ index: label_to_name[label] for index, label in enumerate(labels)
120
+ }
121
+
122
+ while True:
123
+ try:
124
+ values = self.expect_list()
125
+ except ParsingError as list_error:
126
+ try:
127
+ self.rollback()
128
+ self.expect_line(next_line)
129
+ except ParsingError as line_error:
130
+ raise ParsingError(*(list_error.args + line_error.args))
131
+ else:
132
+ break
133
+
134
+ if len(values) != len(index_to_name):
135
+ raise ParsingError("invalid fields for %s" % cls.__name__)
136
+ instance = cls()
137
+ for index, value in enumerate(values):
138
+ name = index_to_name[index]
139
+ setattr(instance, name, value)
140
+ yield instance
141
+
142
+
143
+ class ParsingError(Exception):
144
+ pass
@@ -0,0 +1,16 @@
1
+ from .parser import LogParser
2
+
3
+
4
+ def parse_status(status_log, encoding="utf-8"):
5
+ """Parses the status log of OpenVPN.
6
+
7
+ :param status_log: The content of status log.
8
+ :type status_log: :class:`str`
9
+ :param encoding: Optional. The encoding of status log.
10
+ :type encoding: :class:`str`
11
+ :return: The instance of :class:`.models.Status`
12
+ """
13
+ if isinstance(status_log, bytes):
14
+ status_log = status_log.decode(encoding)
15
+ parser = LogParser.fromstring(status_log)
16
+ return parser.parse()
@@ -0,0 +1,82 @@
1
+ import collections
2
+ import datetime
3
+ import ipaddress
4
+ import re
5
+
6
+ from humanize.filesize import naturalsize
7
+ from netaddr import EUI, mac_unix
8
+
9
+ DATETIME_FORMAT_OPENVPN_V2_5 = "%Y-%m-%d %H:%M:%S"
10
+ DATETIME_FORMAT_OPENVPN_V2_4 = "%a %b %d %H:%M:%S %Y"
11
+ RE_VIRTUAL_ADDR_MAC = re.compile(r"^(?:[a-f0-9]{2}:){5}[a-f0-9]{2}(?:@\d+)?$", re.I)
12
+ RE_VIRTUAL_ADDR_NETWORK = re.compile("/(\\d{1,3})$")
13
+ RE_VIRTUAL_ADDR_CLIENT = re.compile("C$")
14
+
15
+
16
+ def parse_time(time):
17
+ """Parses date and time from input string in OpenVPN logging format."""
18
+ if isinstance(time, datetime.datetime):
19
+ return time
20
+ try:
21
+ # Try the current numeric timestamp before the legacy format.
22
+ return datetime.datetime.strptime(time, DATETIME_FORMAT_OPENVPN_V2_5)
23
+ except ValueError:
24
+ return datetime.datetime.strptime(time, DATETIME_FORMAT_OPENVPN_V2_4)
25
+
26
+
27
+ def parse_peer(peer):
28
+ if isinstance(peer, PeerAddress):
29
+ return peer
30
+ host, port = peer.rsplit(":", 1)
31
+ return PeerAddress(ipaddress.ip_address(host), int(port))
32
+
33
+
34
+ def parse_vaddr(virtual_addr):
35
+ match = RE_VIRTUAL_ADDR_MAC.search(virtual_addr)
36
+ if match:
37
+ return EUI(virtual_addr.split("@")[0], dialect=mac_unix)
38
+
39
+ match = RE_VIRTUAL_ADDR_NETWORK.search(virtual_addr)
40
+ if match and 0 < int(match.group(1)) <= 128:
41
+ return ipaddress.ip_network(virtual_addr)
42
+
43
+ match = RE_VIRTUAL_ADDR_CLIENT.search(virtual_addr)
44
+ if match:
45
+ return ipaddress.ip_address(RE_VIRTUAL_ADDR_CLIENT.sub("", virtual_addr))
46
+
47
+ return ipaddress.ip_address(virtual_addr)
48
+
49
+
50
+ def parse_filesize(size):
51
+ if isinstance(size, FileSize):
52
+ return size
53
+ return FileSize(size)
54
+
55
+
56
+ class PeerAddress(collections.namedtuple("PeerAddress", "host port")):
57
+ """The address of peer entity.
58
+
59
+ :param host: The host address of peer entity.
60
+ :type host: :class:`~ipaddress.IPv4Address` or
61
+ :class:`~ipaddress.IPv6Address`
62
+ :param port: The port of peer entity.
63
+ :type port: :class:`int`
64
+ """
65
+
66
+ def __str__(self):
67
+ return "{0}:{1}".format(self.host, self.port)
68
+
69
+
70
+ class FileSize(int):
71
+ """The size of bytes."""
72
+
73
+ def __str__(self):
74
+ return self.humanize()
75
+
76
+ def humanize(self, **kwargs):
77
+ """Gets the human-friendly representation of file size.
78
+
79
+ :param kwargs: All keyword arguments will be passed to
80
+ :func:`humanize.filesize.naturalsize`.
81
+ """
82
+ return naturalsize(self, **kwargs)
@@ -0,0 +1,16 @@
1
+ VERSION = (0, 3, 0, "final")
2
+
3
+
4
+ def get_version():
5
+ version = "%s.%s.%s" % VERSION[:3]
6
+ if VERSION[3:] == ("alpha", 0):
7
+ return "%s pre-alpha" % version
8
+ if VERSION[3].startswith("post"):
9
+ return "%s.%s" % (version, VERSION[3])
10
+ if VERSION[3] != "final":
11
+ revision = VERSION[4] if len(VERSION) > 4 else 0
12
+ return "%s%s%s" % (version, VERSION[3][0], revision)
13
+ return version
14
+
15
+
16
+ __version__ = get_version()
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: openvpn-status-reader
3
+ Version: 0.3.0
4
+ Summary: Parse OpenVPN status logs in Python
5
+ Home-page: https://github.com/openwisp/openvpn-status-reader
6
+ Author: OpenWISP
7
+ Author-email: support@openwisp.org
8
+ License: MIT
9
+ Keywords: openvpn,status,log
10
+ Platform: Any
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Programming Language :: Python :: Implementation :: CPython
22
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
23
+ Classifier: Topic :: Internet :: Log Analysis
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Topic :: System :: Logging
26
+ Classifier: Topic :: Text Processing
27
+ Classifier: Topic :: Utilities
28
+ Requires-Python: >=3.11
29
+ License-File: LICENSE
30
+ License-File: AUTHORS.rst
31
+ Requires-Dist: humanize<5,>=4.16
32
+ Requires-Dist: netaddr<2,>=1.3
33
+ Dynamic: author
34
+ Dynamic: author-email
35
+ Dynamic: classifier
36
+ Dynamic: description
37
+ Dynamic: home-page
38
+ Dynamic: keywords
39
+ Dynamic: license
40
+ Dynamic: license-file
41
+ Dynamic: platform
42
+ Dynamic: requires-dist
43
+ Dynamic: requires-python
44
+ Dynamic: summary
45
+
46
+ OpenVPN Status Reader
47
+ =====================
48
+
49
+ .. image:: https://github.com/openwisp/openvpn-status-reader/actions/workflows/ci.yml/badge.svg?branch=master
50
+ :target: https://github.com/openwisp/openvpn-status-reader/actions/workflows/ci.yml
51
+ :alt: CI build status
52
+
53
+ .. image:: https://coveralls.io/repos/openwisp/openvpn-status-reader/badge.svg
54
+ :target: https://coveralls.io/r/openwisp/openvpn-status-reader
55
+
56
+ .. image:: https://img.shields.io/librariesio/release/github/openwisp/openvpn-status-reader
57
+ :target: https://libraries.io/github/openwisp/openvpn-status-reader#repository_dependencies
58
+ :alt: Dependency monitoring
59
+
60
+ .. image:: https://img.shields.io/gitter/room/nwjs/nw.js.svg?style=flat-square
61
+ :target: https://gitter.im/openwisp/general
62
+
63
+ .. image:: https://badge.fury.io/py/openvpn-status-reader.svg
64
+ :target: https://badge.fury.io/py/openvpn-status-reader
65
+
66
+ .. image:: https://pepy.tech/badge/openvpn-status-reader
67
+ :target: https://pepy.tech/project/openvpn-status-reader
68
+ :alt: downloads
69
+
70
+ .. image:: https://img.shields.io/badge/code%20style-black-000000.svg
71
+ :target: https://pypi.org/project/black/
72
+ :alt: code style: black
73
+
74
+ .. summary-begin
75
+
76
+ **openvpn-status-reader** is an OpenWISP-maintained Python library for
77
+ parsing OpenVPN status-version-1 logs into Python data structures. It
78
+ supports both legacy and current numeric OpenVPN timestamps.
79
+
80
+ .. summary-end
81
+
82
+ This project is a fork of the original openvpn-status_ repository. It
83
+ retains the ``openvpn_status`` import namespace for compatibility.
84
+
85
+ It supports Python 3.11 and newer.
86
+
87
+ Installation
88
+ ------------
89
+
90
+ .. code-block:: bash
91
+
92
+ pip install openvpn-status-reader
93
+
94
+ Don't forget to put it in ``setup.py`` / ``requirements.txt``.
95
+
96
+ Getting Started
97
+ ---------------
98
+
99
+ You could configure your OpenVPN server to log for client status. In usual
100
+ it could be achieved by adding ``status /path/to/openvpn-status.log`` line
101
+ to ``/etc/openvpn/openvpn.conf``. For example:
102
+
103
+ ::
104
+
105
+ proto udp
106
+ port 1194
107
+ dev tun0
108
+ status /var/run/openvpn-status.log
109
+
110
+ Once OpenVPN server running, the log file will be created and written. It
111
+ looks like:
112
+
113
+ ::
114
+
115
+ OpenVPN CLIENT LIST
116
+ Updated,Thu Jun 18 08:12:15 2015
117
+ Common Name,Real Address,Bytes Received,Bytes Sent,Connected Since
118
+ foo@example.com,10.10.10.10:49502,334948,1973012,Thu Jun 18 04:23:03 2015
119
+ bar@example.com,10.10.10.10:64169,1817262,28981224,Thu Jun 18 04:08:39 2015
120
+ ROUTING TABLE
121
+ Virtual Address,Common Name,Real Address,Last Ref
122
+ 192.168.255.134,foo@example.com,10.10.10.10:49502,Thu Jun 18 08:12:09 2015
123
+ 192.168.255.126,bar@example.com,10.10.10.10:64169,Thu Jun 18 08:11:55 2015
124
+ GLOBAL STATS
125
+ Max bcast/mcast queue length,0
126
+ END
127
+
128
+ Now we could parse log file with this library:
129
+
130
+ .. code-block:: python
131
+
132
+ from openvpn_status import parse_status
133
+
134
+ with open("/var/run/openvpn-status.log") as logfile:
135
+ status = parse_status(logfile.read())
136
+
137
+ print(status.updated_at) # datetime.datetime(2015, 6, 18, 8, 12, 15)
138
+
139
+ foo_client = status.client_list["169.254.0.1"]
140
+ print(foo_client.common_name) # foo@example.com
141
+ print(foo_client.bytes_received) # 334.9 kB
142
+ print(foo_client.bytes_sent) # 2.0 MB
143
+ print(int(foo_client.bytes_sent)) # 2097152
144
+
145
+ More details are in the `API reference`_.
146
+
147
+ .. _api reference: https://openvpn-status-reader.readthedocs.io/en/latest/api.html
148
+
149
+ .. _openvpn-status: https://github.com/sgares/openvpn-status
@@ -0,0 +1,24 @@
1
+ AUTHORS.rst
2
+ LICENSE
3
+ README.rst
4
+ pyproject.toml
5
+ setup.cfg
6
+ setup.py
7
+ openvpn_status/__init__.py
8
+ openvpn_status/descriptors.py
9
+ openvpn_status/models.py
10
+ openvpn_status/parser.py
11
+ openvpn_status/shortcuts.py
12
+ openvpn_status/utils.py
13
+ openvpn_status/version.py
14
+ openvpn_status_reader.egg-info/PKG-INFO
15
+ openvpn_status_reader.egg-info/SOURCES.txt
16
+ openvpn_status_reader.egg-info/dependency_links.txt
17
+ openvpn_status_reader.egg-info/not-zip-safe
18
+ openvpn_status_reader.egg-info/requires.txt
19
+ openvpn_status_reader.egg-info/top_level.txt
20
+ tests/test_descriptors.py
21
+ tests/test_models.py
22
+ tests/test_package_meta.py
23
+ tests/test_parser.py
24
+ tests/test_utils.py
@@ -0,0 +1,2 @@
1
+ humanize<5,>=4.16
2
+ netaddr<2,>=1.3
@@ -0,0 +1,16 @@
1
+ [tool.isort]
2
+ known_first_party = ["openvpn_status"]
3
+ default_section = "THIRDPARTY"
4
+ line_length = 88
5
+ multi_line_output = 3
6
+ use_parentheses = true
7
+ include_trailing_comma = true
8
+ force_grid_wrap = 0
9
+
10
+ [tool.coverage.run]
11
+ branch = true
12
+ source = ["openvpn_status"]
13
+ omit = ["*/tests/*"]
14
+
15
+ [tool.docstrfmt]
16
+ extend_exclude = ["**/*.py"]
@@ -0,0 +1,14 @@
1
+ [bdist_wheel]
2
+ python_tag = py3
3
+
4
+ [tool:pytest]
5
+ addopts = --cov openvpn_status
6
+
7
+ [flake8]
8
+ exclude = setup.py
9
+ max-line-length = 110
10
+
11
+ [egg_info]
12
+ tag_build =
13
+ tag_date = 0
14
+
@@ -0,0 +1,51 @@
1
+ import sys
2
+
3
+ from setuptools import find_packages, setup
4
+
5
+ sys.path.insert(0, "openvpn_status")
6
+ from version import get_version
7
+
8
+ sys.path.remove("openvpn_status")
9
+
10
+ with open("README.rst") as readme:
11
+ long_description = readme.read().strip()
12
+
13
+
14
+ setup(
15
+ name="openvpn-status-reader",
16
+ version=get_version(),
17
+ url="https://github.com/openwisp/openvpn-status-reader",
18
+ author="OpenWISP",
19
+ author_email="support@openwisp.org",
20
+ description="Parse OpenVPN status logs in Python",
21
+ long_description=long_description,
22
+ packages=find_packages(),
23
+ zip_safe=False,
24
+ license="MIT",
25
+ keywords=["openvpn", "status", "log"],
26
+ classifiers=[
27
+ "Development Status :: 4 - Beta",
28
+ "Intended Audience :: Developers",
29
+ "Intended Audience :: System Administrators",
30
+ "License :: OSI Approved :: MIT License",
31
+ "Operating System :: OS Independent",
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.11",
34
+ "Programming Language :: Python :: 3.12",
35
+ "Programming Language :: Python :: 3.13",
36
+ "Programming Language :: Python :: 3.14",
37
+ "Programming Language :: Python :: Implementation :: CPython",
38
+ "Programming Language :: Python :: Implementation :: PyPy",
39
+ "Topic :: Internet :: Log Analysis",
40
+ "Topic :: Software Development :: Libraries :: Python Modules",
41
+ "Topic :: System :: Logging",
42
+ "Topic :: Text Processing",
43
+ "Topic :: Utilities",
44
+ ],
45
+ install_requires=[
46
+ "humanize>=4.16,<5",
47
+ "netaddr>=1.3,<2",
48
+ ],
49
+ python_requires=">=3.11",
50
+ platforms=["Any"],
51
+ )
@@ -0,0 +1,38 @@
1
+ from pytest import fixture, raises
2
+
3
+ from openvpn_status.descriptors import LabelProperty, iter_descriptors, name_descriptors
4
+
5
+
6
+ @fixture
7
+ def foo_class():
8
+ @name_descriptors
9
+ class Foo:
10
+ foo = LabelProperty("Foo")
11
+ bar = LabelProperty("Bar", default=lambda: 0, input_type=int)
12
+ baz = property(lambda self: self.bar)
13
+ biu = ()
14
+
15
+ return Foo
16
+
17
+
18
+ def test_label_and_its_name(foo_class):
19
+ foo = foo_class()
20
+ with raises(AttributeError):
21
+ foo.foo
22
+ assert foo.bar == 0
23
+ assert foo.baz == 0
24
+
25
+ foo.foo = "1"
26
+ foo.bar = "2"
27
+
28
+ assert foo.foo == "1"
29
+ assert foo.bar == 2
30
+ assert foo.baz == 2
31
+
32
+
33
+ def test_iter_descriptors(foo_class):
34
+ assert dict(iter_descriptors(foo_class)) == {
35
+ "foo": foo_class.foo,
36
+ "bar": foo_class.bar,
37
+ "baz": foo_class.baz,
38
+ }
@@ -0,0 +1,105 @@
1
+ from collections import OrderedDict
2
+ from datetime import datetime
3
+ from ipaddress import IPv4Address
4
+ from itertools import combinations
5
+
6
+ from openvpn_status.models import Client, GlobalStats, Routing, Status
7
+
8
+
9
+ def is_inequality(iterable):
10
+ return all(left != right for left, right in combinations(iterable, 2))
11
+
12
+
13
+ def test_status():
14
+ status = Status()
15
+ assert isinstance(status.client_list, OrderedDict)
16
+ assert isinstance(status.routing_table, OrderedDict)
17
+
18
+ assert getattr(status, "client_list", None) is not None
19
+ assert getattr(status, "routing_table", None) is not None
20
+ assert getattr(status, "global_stats", None) is None
21
+ assert getattr(status, "updated_at", None) is None
22
+
23
+ now = datetime.now()
24
+ status.updated_at = now
25
+ assert status.updated_at == now
26
+
27
+
28
+ def test_status_properties():
29
+ status = Status()
30
+ status.client_list.update((i, i) for i in range(3))
31
+ assert len(status.client_list) == 3
32
+ assert status.client_list[0] == 0
33
+ assert status.client_list[1] == 1
34
+ assert status.client_list[2] == 2
35
+
36
+
37
+ def test_status_labels():
38
+ assert is_inequality(
39
+ [
40
+ Status.client_list.label,
41
+ Status.routing_table.label,
42
+ Status.global_stats.label,
43
+ ]
44
+ )
45
+
46
+
47
+ def test_client():
48
+ client = Client()
49
+ assert getattr(client, "common_name", None) is None
50
+ assert getattr(client, "real_address", None) is None
51
+ assert getattr(client, "bytes_received", None) is None
52
+ assert getattr(client, "bytes_sent", None) is None
53
+ assert getattr(client, "connected_since", None) is None
54
+
55
+ client.bytes_received = 532895
56
+ assert client.bytes_received.humanize() == "532.9 kB"
57
+
58
+ client.bytes_sent = 34254
59
+ assert client.bytes_received.humanize(gnu=True) == "520.4K"
60
+
61
+
62
+ def test_client_labels():
63
+ assert is_inequality(
64
+ [
65
+ Client.common_name.label,
66
+ Client.real_address.label,
67
+ Client.bytes_received.label,
68
+ Client.bytes_sent.label,
69
+ Client.connected_since.label,
70
+ ]
71
+ )
72
+
73
+
74
+ def test_routing():
75
+ routing = Routing()
76
+ assert getattr(routing, "virtual_address", None) is None
77
+ assert getattr(routing, "common_name", None) is None
78
+ assert getattr(routing, "real_address", None) is None
79
+ assert getattr(routing, "last_ref", None) is None
80
+
81
+ routing.virtual_address = "172.16.1.1"
82
+ assert routing.virtual_address == IPv4Address("172.16.1.1")
83
+
84
+ routing.real_address = "192.168.1.1:8080"
85
+ assert routing.real_address.host == IPv4Address("192.168.1.1")
86
+ assert routing.real_address.port == 8080
87
+
88
+
89
+ def test_routing_labels():
90
+ assert is_inequality(
91
+ [
92
+ Routing.virtual_address.label,
93
+ Routing.common_name.label,
94
+ Routing.real_address,
95
+ Routing.last_ref,
96
+ ]
97
+ )
98
+
99
+
100
+ def test_global_stats():
101
+ global_stats = GlobalStats()
102
+ assert getattr(global_stats, "max_bcast_mcast_queue_len", None) is None
103
+
104
+ global_stats.max_bcast_mcast_queue_len = 0
105
+ assert global_stats.max_bcast_mcast_queue_len == 0
@@ -0,0 +1,31 @@
1
+ from pytest import mark, raises
2
+
3
+ import openvpn_status
4
+ from openvpn_status import version as version_module
5
+ from openvpn_status.version import VERSION, get_version
6
+
7
+
8
+ def test_version():
9
+ assert VERSION == (0, 3, 0, "final")
10
+ assert openvpn_status.__version__ == "0.3.0"
11
+
12
+
13
+ def test_shortcut():
14
+ with raises(openvpn_status.ParsingError):
15
+ openvpn_status.parse_status("")
16
+ with raises(openvpn_status.ParsingError):
17
+ openvpn_status.parse_status(b"")
18
+
19
+
20
+ @mark.parametrize(
21
+ "version, expected",
22
+ [
23
+ ((1, 2, 3, "alpha", 0), "1.2.3 pre-alpha"),
24
+ ((1, 2, 3, "post1"), "1.2.3.post1"),
25
+ ((1, 2, 3, "beta", 4), "1.2.3b4"),
26
+ ((1, 2, 3, "rc"), "1.2.3r0"),
27
+ ],
28
+ )
29
+ def test_get_version(monkeypatch, version, expected):
30
+ monkeypatch.setattr(version_module, "VERSION", version)
31
+ assert get_version() == expected
@@ -0,0 +1,141 @@
1
+ import datetime
2
+ import ipaddress
3
+
4
+ from netaddr import EUI
5
+ from pytest import fixture, mark, raises
6
+
7
+ from openvpn_status.parser import LogParser, ParsingError
8
+
9
+
10
+ @fixture(params=["openvpn-status.txt", "openvpn-status-openvpn-2-7.txt"])
11
+ def openvpn_status(datadir, request):
12
+ return datadir.join(request.param)
13
+
14
+
15
+ @fixture
16
+ def broken_status(datadir):
17
+ return datadir.join("broken-openvpn-status")
18
+
19
+
20
+ def test_parser(openvpn_status):
21
+ parser = LogParser.fromstring(openvpn_status.read())
22
+ status = parser.parse()
23
+
24
+ assert len(status.client_list) == 5
25
+ assert len(status.routing_table) == 9
26
+
27
+ assert status.global_stats.max_bcast_mcast_queue_len == 0
28
+ assert status.updated_at == datetime.datetime(2015, 6, 18, 8, 12, 15)
29
+
30
+ client = status.client_list["10.10.10.10:49502"]
31
+ assert client.common_name == "foo@example.com"
32
+ assert str(client.real_address) == "10.10.10.10:49502"
33
+ assert str(client.real_address.host) == "10.10.10.10"
34
+ assert client.real_address.port == 49502
35
+ assert client.connected_since == datetime.datetime(2015, 6, 18, 4, 23, 3)
36
+ assert client.bytes_received == 334948
37
+ assert client.bytes_sent == 1973012
38
+
39
+ tun_routing = status.routing_table["192.168.255.134"]
40
+ assert isinstance(tun_routing.virtual_address, ipaddress.IPv4Address)
41
+ assert str(tun_routing.virtual_address) == "192.168.255.134"
42
+ assert tun_routing.common_name == "foo@example.com"
43
+ assert str(tun_routing.real_address) == "10.10.10.10:49502"
44
+ assert tun_routing.last_ref == datetime.datetime(2015, 6, 18, 8, 12, 9)
45
+
46
+ tap_routing = status.routing_table["22:1d:63:bf:62:38"]
47
+ assert isinstance(tap_routing.virtual_address, EUI)
48
+ assert str(tap_routing.virtual_address) == "22:1d:63:bf:62:38"
49
+ assert tap_routing.common_name == "tap@example.com"
50
+ assert str(tap_routing.real_address) == "10.0.0.100:55712"
51
+ assert tap_routing.last_ref == datetime.datetime(2017, 10, 19, 20, 14, 19)
52
+
53
+ ccd_routing = status.routing_table["10.200.0.0/16"]
54
+ assert isinstance(ccd_routing.virtual_address, ipaddress.IPv4Network)
55
+ assert str(ccd_routing.virtual_address) == "10.200.0.0/16"
56
+ assert ccd_routing.common_name == "baz@example.com"
57
+ assert str(ccd_routing.real_address) == "10.10.10.10:63414"
58
+ assert ccd_routing.last_ref == datetime.datetime(2015, 6, 18, 8, 12, 9)
59
+
60
+ ccd_v6_routing = status.routing_table["2001:db8::1000/124"]
61
+ assert isinstance(ccd_v6_routing.virtual_address, ipaddress.IPv6Network)
62
+ assert str(ccd_v6_routing.virtual_address) == "2001:db8::1000/124"
63
+ assert ccd_v6_routing.common_name == "baz@example.com"
64
+ assert str(ccd_v6_routing.real_address) == "10.10.10.10:63414"
65
+ assert ccd_v6_routing.last_ref == datetime.datetime(2015, 6, 18, 8, 12, 9)
66
+
67
+
68
+ def test_parser_with_syntax_errors(broken_status):
69
+ def catch_syntax_error(seq):
70
+ datafile = broken_status.join("%d.txt" % seq)
71
+ parser = LogParser.fromstring(datafile.read())
72
+ with raises(ParsingError) as error:
73
+ parser.parse()
74
+ return error
75
+
76
+ error = catch_syntax_error(0)
77
+ assert not error.value.args[0].startswith("expected list")
78
+ assert not error.value.args[0].startswith("expected 2-tuple")
79
+ assert error.value.args[0].endswith("got end of input")
80
+
81
+ error = catch_syntax_error(1)
82
+ assert not error.value.args[0].startswith("expected list")
83
+ assert not error.value.args[0].startswith("expected 2-tuple")
84
+ assert error.value.args[0].endswith("got %r" % "BrokenVPN CLIENT LIST")
85
+
86
+ error = catch_syntax_error(2)
87
+ assert error.value.args[0] == "expected list but got end of input"
88
+
89
+ error = catch_syntax_error(3)
90
+ assert error.value.args[0] == "expected 2-tuple but got end of input"
91
+
92
+ error = catch_syntax_error(4)
93
+ assert error.value.args[0] == "expected 2-tuple but got %r" % "Updated,Yo,Hoo"
94
+
95
+ error = catch_syntax_error(5)
96
+ assert error.value.args[0] == "expected 2-tuple starting with %r" % "Updated"
97
+
98
+ error = catch_syntax_error(6)
99
+ assert error.value.args[0] == "expected list but got %r" % "YO TABLE"
100
+
101
+ error = catch_syntax_error(7)
102
+ assert error.value.args[0].startswith("expected valid format: time data ")
103
+
104
+
105
+ @mark.parametrize(
106
+ "labels,values",
107
+ [
108
+ (
109
+ [
110
+ "Common Name",
111
+ "Real Address",
112
+ "Bytes Received",
113
+ "Bytes Sent",
114
+ "Unexpected",
115
+ ],
116
+ ["foo", "10.10.10.10:49502", "1", "1", "value"],
117
+ ),
118
+ (
119
+ [
120
+ "Common Name",
121
+ "Real Address",
122
+ "Bytes Received",
123
+ "Bytes Sent",
124
+ "Connected Since",
125
+ ],
126
+ ["foo", "10.10.10.10:49502", "1", "1", "2026-10-20 17:48:08", "extra"],
127
+ ),
128
+ ],
129
+ )
130
+ def test_parser_rejects_invalid_field_schema(labels, values):
131
+ content = "\n".join(
132
+ [
133
+ "OpenVPN CLIENT LIST",
134
+ "Updated,2026-10-20 17:48:08",
135
+ ",".join(labels),
136
+ ",".join(values),
137
+ ]
138
+ )
139
+
140
+ with raises(ParsingError, match="invalid fields"):
141
+ LogParser.fromstring(content).parse()
@@ -0,0 +1,75 @@
1
+ from datetime import datetime
2
+ from ipaddress import IPv4Address, IPv4Network, IPv6Address, IPv6Network
3
+
4
+ from netaddr import EUI, mac_unix
5
+ from pytest import mark
6
+
7
+ from openvpn_status.utils import (
8
+ FileSize,
9
+ PeerAddress,
10
+ parse_filesize,
11
+ parse_peer,
12
+ parse_time,
13
+ parse_vaddr,
14
+ )
15
+
16
+
17
+ @mark.parametrize(
18
+ "text,time",
19
+ [
20
+ ("Thu Jun 18 04:23:03 2015", datetime(2015, 6, 18, 4, 23, 3)),
21
+ ("Thu Jun 18 04:08:39 2015", datetime(2015, 6, 18, 4, 8, 39)),
22
+ ("Thu Jun 18 07:57:25 2015", datetime(2015, 6, 18, 7, 57, 25)),
23
+ ("2026-10-20 17:48:08", datetime(2026, 10, 20, 17, 48, 8)),
24
+ (datetime(2015, 6, 18, 7, 57, 25), datetime(2015, 6, 18, 7, 57, 25)),
25
+ ],
26
+ )
27
+ def test_parse_time(text, time):
28
+ assert parse_time(text) == time
29
+
30
+
31
+ @mark.parametrize(
32
+ "text,peer",
33
+ [
34
+ ("10.0.0.1:49502", PeerAddress(IPv4Address("10.0.0.1"), 49502)),
35
+ ("10.0.0.2:64169", PeerAddress(IPv4Address("10.0.0.2"), 64169)),
36
+ ("10.0.0.3:63414", PeerAddress(IPv4Address("10.0.0.3"), 63414)),
37
+ (PeerAddress("10.0.0.1", 80), PeerAddress("10.0.0.1", 80)),
38
+ ],
39
+ )
40
+ def test_parse_peer(text, peer):
41
+ assert parse_peer(text) == peer
42
+ assert str(text) == str(peer)
43
+
44
+
45
+ @mark.parametrize(
46
+ "text,virtual_addr",
47
+ [
48
+ ("10.0.0.1", IPv4Address("10.0.0.1")),
49
+ ("2001:db8:2de::e13", IPv6Address("2001:db8:2de::e13")),
50
+ ("10.200.0.0/16", IPv4Network("10.200.0.0/16")),
51
+ ("2001:db8::1000/124", IPv6Network("2001:db8::1000/124")),
52
+ ("28:d2:44:d4:e6:ea", EUI("28:d2:44:d4:e6:ea", dialect=mac_unix)),
53
+ ],
54
+ )
55
+ def test_parse_vaddr(text, virtual_addr):
56
+ assert parse_vaddr(text) == virtual_addr
57
+ assert str(text) == str(virtual_addr)
58
+
59
+
60
+ def test_parse_vaddr_with_interface_index():
61
+ assert parse_vaddr("28:d2:44:d4:e6:ea@5") == EUI(
62
+ "28:d2:44:d4:e6:ea", dialect=mac_unix
63
+ )
64
+
65
+
66
+ @mark.parametrize(
67
+ "text,humanized",
68
+ [
69
+ (10240, "10.2 kB"),
70
+ ("10240", "10.2 kB"),
71
+ (FileSize(10240), "10.2 kB"),
72
+ ],
73
+ )
74
+ def test_parse_filesize(text, humanized):
75
+ assert str(parse_filesize(text)) == humanized