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.
- openvpn_status_reader-0.3.0/AUTHORS.rst +1 -0
- openvpn_status_reader-0.3.0/LICENSE +20 -0
- openvpn_status_reader-0.3.0/PKG-INFO +149 -0
- openvpn_status_reader-0.3.0/README.rst +104 -0
- openvpn_status_reader-0.3.0/openvpn_status/__init__.py +5 -0
- openvpn_status_reader-0.3.0/openvpn_status/descriptors.py +51 -0
- openvpn_status_reader-0.3.0/openvpn_status/models.py +44 -0
- openvpn_status_reader-0.3.0/openvpn_status/parser.py +144 -0
- openvpn_status_reader-0.3.0/openvpn_status/shortcuts.py +16 -0
- openvpn_status_reader-0.3.0/openvpn_status/utils.py +82 -0
- openvpn_status_reader-0.3.0/openvpn_status/version.py +16 -0
- openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/PKG-INFO +149 -0
- openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/SOURCES.txt +24 -0
- openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/dependency_links.txt +1 -0
- openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/not-zip-safe +1 -0
- openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/requires.txt +2 -0
- openvpn_status_reader-0.3.0/openvpn_status_reader.egg-info/top_level.txt +1 -0
- openvpn_status_reader-0.3.0/pyproject.toml +16 -0
- openvpn_status_reader-0.3.0/setup.cfg +14 -0
- openvpn_status_reader-0.3.0/setup.py +51 -0
- openvpn_status_reader-0.3.0/tests/test_descriptors.py +38 -0
- openvpn_status_reader-0.3.0/tests/test_models.py +105 -0
- openvpn_status_reader-0.3.0/tests/test_package_meta.py +31 -0
- openvpn_status_reader-0.3.0/tests/test_parser.py +141 -0
- 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,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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
openvpn_status
|
|
@@ -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,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
|