6b2b3a4d07d0ee135998100c5f323012f126691d
[pyi2ncommon] / src / connd_state.py
1 #!/usr/bin/env python
2
3 # The software in this package is distributed under the GNU General
4 # Public License version 2 (with a special exception described below).
5 #
6 # A copy of GNU General Public License (GPL) is included in this distribution,
7 # in the file COPYING.GPL.
8 #
9 # As a special exception, if other files instantiate templates or use macros
10 # or inline functions from this file, or you compile this file and link it
11 # with other works to produce a work based on this file, this file
12 # does not by itself cause the resulting work to be covered
13 # by the GNU General Public License.
14 #
15 # However the source code for this file must still be made available
16 # in accordance with section (3) of the GNU General Public License.
17 #
18 # This exception does not invalidate any other reasons why a work based
19 # on this file might be covered by the GNU General Public License.
20 #
21 # Copyright (c) 2016-2018 Intra2net AG <info@intra2net.com>
22
23 """
24
25 SUMMARY
26 ------------------------------------------------------
27 Representation for connd state as returned by "tell-connd --status".
28
29 Copyright: Intra2net AG
30
31
32 INTERFACE
33 ------------------------------------------------------
34
35 """
36
37 from __future__ import print_function
38 import subprocess
39 from re import match as regexp
40 from os import EX_OK
41
42 # constants
43 DEFAULT_TELL_CONND_BINARY = '/usr/intranator/bin/tell-connd'
44 # TIMEOUT = 1   can only be used with python3
45
46 ONLINE_MODE_ALWAYS_ONLINE = 'always online'
47 ONLINE_MODE_ALWAYS_OFFLINE = 'always offline'
48 ONLINE_MODE_DIAL_ON_COMMAND = 'dial on command'
49 ONLINE_MODE_DIAL_ON_DEMAND = 'dial on demand'
50
51 SUBSYS_DNS = 'dns'
52 SUBSYS_DYNDNS = 'dyndns'
53 SUBSYS_MAIL = 'mail'
54 SUBSYS_NTP = 'ntp'
55 SUBSYS_SOCKS = 'socks'
56 SUBSYS_VPN = 'vpn'
57 SUBSYS_WEBPROXY = 'webproxy'
58 SUBSYS_PINGCHECK = 'pingcheck'
59 SUBSYS_IPONLINE = 'iponline'
60 ALL_SUBSYS = (SUBSYS_DNS, SUBSYS_DYNDNS, SUBSYS_MAIL, SUBSYS_NTP,
61               SUBSYS_SOCKS, SUBSYS_VPN, SUBSYS_WEBPROXY, SUBSYS_PINGCHECK,
62               SUBSYS_IPONLINE)
63
64 ALL_MODES = (ONLINE_MODE_DIAL_ON_DEMAND, ONLINE_MODE_DIAL_ON_COMMAND,
65              ONLINE_MODE_ALWAYS_OFFLINE, ONLINE_MODE_ALWAYS_ONLINE)
66
67
68 class ConndState(object):
69     """Representation of connd's status as returned by tell-connd --status."""
70
71     online_mode = None
72     default_provider = None
73     subsys_online = None
74     subsys_offline = None
75     subsys_disabled = None
76     connections = None
77     actions = None
78     online_ips = None
79     connected_vpns = None
80     log_level = None
81     log_file = None
82
83     def __str__(self):
84         return \
85             '[ConndState: {0} (default {1}), {2} conn\'s, {3} ips, {4} vpns ]'\
86             .format(self.online_mode, self.default_provider,
87                     len(self.connections), len(self.online_ips),
88                     len(self.connected_vpns))
89
90     def complete_str(self):
91         """Return a string representating the complete state."""
92
93         # general
94         parts = [
95             'ConndState: online mode = "{0}" (default provider: {1})\n'
96             .format(self.online_mode, self.default_provider), ]
97
98         # subsys
99         #            '  connctns:   (repeated here for correct aligning)
100         parts.append('    subsys: online: ')
101         if self.subsys_online:
102             for subsys in self.subsys_online:
103                 parts.append(subsys + ' ')
104         else:
105             parts.append('None ')
106         parts.append(';  offline: ')
107         if self.subsys_offline:
108             for subsys in self.subsys_offline:
109                 parts.append(subsys + ' ')
110         else:
111             parts.append('None ')
112         parts.append(';  disabled: ')
113         if self.subsys_disabled:
114             for subsys in self.subsys_disabled:
115                 parts.append(subsys + ' ')
116         else:
117             parts.append('None')
118         parts.append('\n')
119
120         # connections
121         parts.append('     conns: ')
122         if self.connections:
123             name, info, actions = self.connections[0]
124             parts.append('{0}: {1}, {2}\n'.format(name, info, actions))
125         else:
126             parts.append('None\n')
127         for name, info, actions in self.connections[1:]:
128             #            '  connctns:   (repeated here for correct aligning)
129             parts.append('            {0}: {1}, {2}\n'.format(name, info,
130                                                               actions))
131
132         # actions
133         #            '  connctns:   (repeated here for correct aligning)
134         parts.append('   actions: ')
135         if self.actions:
136             parts.append(self.actions[0] + '\n')
137         else:
138             parts.append('None\n')
139         for action in self.actions[1:]:
140             #            '  connctns:   (repeated here for correct aligning)
141             parts.append('            {0}\n'.format(action))
142
143         # online IPs
144         #            '  connctns:   (repeated here for correct aligning)
145         parts.append('       IPs: ')
146         if self.online_ips:
147             parts.append(self.online_ips[0])
148             for curr_ip in self.online_ips[1:]:
149                 parts.append(', {0}'.format(curr_ip))
150         else:
151             parts.append('None')
152         parts.append('\n')
153
154         # VPNs
155         #            '  connctns:   (repeated here for correct aligning)
156         parts.append('      VPNs: ')
157         if self.connected_vpns:
158             parts.append(self.connected_vpns[0])
159             for vpn in self.connected_vpns[1:]:
160                 parts.append(', {0}'.format(vpn))
161         else:
162             parts.append('None')
163         parts.append('\n')
164
165         # log level and target:
166         #            '  connctns:   (repeated here for correct aligning)
167         parts.append('       Log: level {0}'.format(self.log_level))
168         if self.log_file:
169             parts.append(' to {0}'.format(self.log_file))
170         parts.append('\n')
171
172         return ''.join(parts)
173
174     @staticmethod
175     def run_tell_connd(tell_connd_binary=DEFAULT_TELL_CONND_BINARY, *args):
176         """
177         Run "tell-connd --status", return output iterator and return code.
178
179         Catches all it can, so should usually return (output, return_code)
180           where output = [line1, line2, ...]
181
182         If return_code != 0, output's first line(s) is error message.
183
184         .. todo:: Use reST parameter description here.
185         """
186         try:
187             cmd_parts = [tell_connd_binary, ]
188             cmd_parts.extend(*args)
189             output = subprocess.check_output(cmd_parts,
190                                              stderr=subprocess.STDOUT,
191                                              universal_newlines=True, shell=False)  # py3:, timeout=TIMEOUT)
192             return EX_OK, output.splitlines()
193         except subprocess.CalledProcessError as cpe:  # non-zero return status
194             output = [
195                 'tell-connd exited with status {0}'.format(cpe.returncode), ]
196             output.extend(cpe.output.splitlines())
197             return cpe.returncode, output
198         # not python-2-compatible:
199         # except subprocess.TimeoutExpired as texp:
200         #     output = [
201         #         'tell-connd timed out after {0}s. Returning -1'.format(
202         #             texp.timeout), ]
203         #     output.extend(te.output.splitlines())
204         #     return -1, output
205         except Exception as exp:
206             output = [str(exp), ]
207             return -1, output
208
209     @staticmethod
210     def get_state(tell_connd_binary=DEFAULT_TELL_CONND_BINARY):
211         """
212         Get actual state from "tell-connd --status".
213
214         Returns (err_code, output_lines) if something goes wrong running
215           binary; raises assertion if output from tell-connd does not match
216           expected format.
217
218         .. todo:: Use reST parameter description here.
219         """
220
221         state = ConndState()
222
223         err_code, all_lines = ConndState.run_tell_connd(tell_connd_binary,
224                                                         ['--status', ])
225         if err_code != EX_OK:
226             return err_code, all_lines
227
228         output = iter(all_lines)
229
230         # first section
231         line = next(output).strip()
232         state.online_mode = regexp('online mode\s*:\s*(.+)$', line).groups()[0]
233         assert state.online_mode in ALL_MODES, \
234             'unexpected online mode: {0}'.format(state.online_mode)
235
236         line = next(output).strip()
237         state.default_provider = regexp('default provider\s*:\s*(.*)$',
238                                         line).groups()[0]
239         if len(state.default_provider) == 0:
240             state.default_provider = None
241         line = next(output).strip()
242         assert len(line) == 0, 'expected empty line, but got {0}'.format(line)
243
244         # subsys
245         line = next(output).strip()
246         assert line == 'subsys', 'expected subsys but got {0}'.format(line)
247         line = next(output).strip()
248         state.subsys_online = regexp('online\s*:\s*(.*)$', line)\
249             .groups()[0].split()
250         for subsys in state.subsys_online:
251             assert subsys in ALL_SUBSYS, \
252                 'unexpected subsys: {0}'.format(subsys)
253         line = next(output).strip()
254         state.subsys_offline = regexp('offline\s*:\s*(.*)$', line)\
255             .groups()[0].split()
256         for subsys in state.subsys_offline:
257             assert subsys in ALL_SUBSYS, \
258                 'unexpected subsys: {0}'.format(subsys)
259         line = next(output).strip()
260         state.subsys_disabled = regexp('disabled\s*:\s*(.*)$', line)\
261             .groups()[0].split()
262         for subsys in state.subsys_disabled:
263             assert subsys in ALL_SUBSYS, \
264                 'unexpected subsys: {0}'.format(subsys)
265         line = next(output).strip()
266         assert len(line) == 0, 'expected empty line, but got {0}'.format(line)
267
268         # connection map
269         state.connections = []
270         line = next(output).strip()
271         assert line == 'connection map:', \
272             'expected connection map but got {0}'.format(line)
273         expect_new = True
274         for line in output:
275             line = line.strip()
276             if len(line) == 0:
277                 continue
278             if expect_new:
279                 if line == 'end of connection map':
280                     break
281                 conn_name, conn_info = regexp(
282                     '\[\s*(.+)\s*\]\s*:\s*\(\s*(.*)\s*\)', line).groups()
283                 expect_new = False
284             else:
285                 conn_actions = regexp('actions\s*:\s*\[\s*(.+)\s*\]', line)\
286                     .groups()
287                 state.connections.append((conn_name, conn_info, conn_actions))
288                 expect_new = True
289         assert expect_new
290         line = next(output).strip()
291         assert len(line) == 0, 'expected empty line, but got {0}'.format(line)
292
293         # actions
294         line = next(output).strip()
295         state.actions = regexp('actions\s*:\s*(.*)', line).groups()[0].split()
296         if len(state.actions) == 1 and state.actions[0].strip() == '-':
297             state.actions = []
298         line = next(output).strip()
299         assert len(line) == 0, 'expected empty line, but got {0}'.format(line)
300
301         # online IPs
302         line = next(output).strip()
303         state.online_ips = regexp('list of online ips\s*:\s*(.*)', line)\
304             .groups()[0].split()
305         if len(state.online_ips) == 1 \
306                 and state.online_ips[0].strip() == 'NONE':
307             state.online_ips = []
308         line = next(output).strip()
309         assert len(line) == 0, 'expected empty line, but got {0}'.format(line)
310
311         # VPNs
312         state.connected_vpns = []
313         line = next(output).strip()
314         assert line == 'vpns connected:', \
315             'expected vpns connected, got {0}'.format(line)
316         for line in output:
317             line = line.strip()
318             if len(line) == 0:
319                 continue
320             elif line == 'end of list of connected vpns':
321                 break
322             else:
323                 state.connected_vpns.append(line)
324         line = next(output).strip()
325         assert len(line) == 0, 'expected empty line, but got {0}'.format(line)
326
327         # log level
328         line = next(output).strip()
329         state.log_level, state.log_file = \
330             regexp('Logging with level (.+)(?:\s+to\s+(.+))?', line).groups()
331
332         # done
333         line = next(output).strip()
334         assert len(line) == 0, 'expected empty line, but got {0}'.format(line)
335         line = next(output).strip()
336         assert line == 'Done.', 'expect Done but got {0}'.format(line)
337
338         return state
339
340     @staticmethod
341     def set_online_mode(state, provider=None,
342                         tell_connd_binary=DEFAULT_TELL_CONND_BINARY):
343         """
344         Change online state with optional provider.
345
346         Provider is silently ignored for ONLINE_MODE_ALWAYS_OFFLINE and
347         otherwise required.
348
349         Returns result of :py:func:`run_tell_connd`: (error_code, output_lines).
350         """
351
352         # check args
353         need_provider = True
354         if state == ONLINE_MODE_DIAL_ON_DEMAND:
355             args = ['--dial-on-demand', provider]
356         elif state == ONLINE_MODE_DIAL_ON_COMMAND:
357             args = ['--dial-on-command', provider]
358         elif state == ONLINE_MODE_ALWAYS_ONLINE:
359             args = ['--online', provider]
360         elif state == ONLINE_MODE_ALWAYS_OFFLINE:
361             args = ['--offline', ]
362             need_provider = False
363         else:
364             raise ValueError('unknown state: {0}!'.format(state))
365         if need_provider and not provider:
366             raise ValueError('Given state {0} requires a provider!'.format(
367                 state))
368
369         # run binary
370         return ConndState.run_tell_connd(tell_connd_binary, args)
371
372
373 def test():
374     """Get state and print it."""
375     state = ConndState.get_state()
376     if not isinstance(state, ConndState):
377         err_code, output_lines = state
378         print('tell-connd failed with error code {0} and output:'.format(
379             err_code))
380         for line in output_lines:
381             print('tell-connd: {0}'.format(line))
382         print('(end of tell-connd output)')
383     else:
384         print(state)
385         print(state.complete_str())
386
387
388 def main():
389     """Main function, called when running file as script; runs test()."""
390     test()
391
392
393 if __name__ == '__main__':
394     main()