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