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