ensure mtu is in the ipif substitution set
[hippotat] / hippotatlib / __init__.py
1 # -*- python -*-
2 #
3 # Hippotat - Asinine IP Over HTTP program
4 # hippotatlib/__init__.py - common library code
5 #
6 # Copyright 2017 Ian Jackson
7 #
8 # GPLv3+
9 #
10 # This program is free software: you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation, either version 3 of the License, or
13 # (at your option) any later version.
14 #
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18 # GNU General Public License for more details.
19 #
20 # You should have received a copy of the GNU General Public License
21 # along with this program, in the file GPLv3. If not,
22 # see <http://www.gnu.org/licenses/>.
23
24
25 import signal
26 signal.signal(signal.SIGINT, signal.SIG_DFL)
27
28 import sys
29 import os
30
31 from zope.interface import implementer
32
33 import twisted
34 from twisted.internet import reactor
35 import twisted.internet.endpoints
36 import twisted.logger
37 from twisted.logger import LogLevel
38 import twisted.python.constants
39 from twisted.python.constants import NamedConstant
40
41 import ipaddress
42 from ipaddress import AddressValueError
43
44 from optparse import OptionParser
45 import configparser
46 from configparser import ConfigParser
47 from configparser import NoOptionError
48
49 from functools import partial
50
51 import collections
52 import time
53 import hmac
54 import hashlib
55 import base64
56 import codecs
57 import traceback
58
59 import re as regexp
60
61 import hippotatlib.slip as slip
62
63 class DBG(twisted.python.constants.Names):
64 INIT = NamedConstant()
65 CONFIG = NamedConstant()
66 ROUTE = NamedConstant()
67 DROP = NamedConstant()
68 OWNSOURCE = NamedConstant()
69 FLOW = NamedConstant()
70 HTTP = NamedConstant()
71 TWISTED = NamedConstant()
72 QUEUE = NamedConstant()
73 HTTP_CTRL = NamedConstant()
74 QUEUE_CTRL = NamedConstant()
75 HTTP_FULL = NamedConstant()
76 CTRL_DUMP = NamedConstant()
77 SLIP_FULL = NamedConstant()
78 DATA_COMPLETE = NamedConstant()
79
80 _hex_codec = codecs.getencoder('hex_codec')
81
82 #---------- logging ----------
83
84 org_stderr = sys.stderr
85
86 log = twisted.logger.Logger()
87
88 debug_set = set()
89 debug_def_detail = DBG.HTTP
90
91 def log_debug(dflag, msg, idof=None, d=None):
92 if dflag not in debug_set: return
93 #print('---------------->',repr((dflag, msg, idof, d)), file=sys.stderr)
94 if idof is not None:
95 msg = '[%#x] %s' % (id(idof), msg)
96 if d is not None:
97 trunc = ''
98 if not DBG.DATA_COMPLETE in debug_set:
99 if len(d) > 64:
100 d = d[0:64]
101 trunc = '...'
102 d = _hex_codec(d)[0].decode('ascii')
103 msg += ' ' + d + trunc
104 log.info('{dflag} {msgcore}', dflag=dflag, msgcore=msg)
105
106 def logevent_is_boringtwisted(event):
107 try:
108 if event.get('log_level') != LogLevel.info:
109 return False
110 dflag = event.get('dflag')
111 if dflag is False : return False
112 if dflag in debug_set: return False
113 if dflag is None and DBG.TWISTED in debug_set: return False
114 return True
115 except Exception:
116 print('EXCEPTION (IN BORINGTWISTED CHECK)',
117 traceback.format_exc(), file=org_stderr)
118 return False
119
120 @implementer(twisted.logger.ILogFilterPredicate)
121 class LogNotBoringTwisted:
122 def __call__(self, event):
123 return (
124 twisted.logger.PredicateResult.no
125 if logevent_is_boringtwisted(event) else
126 twisted.logger.PredicateResult.yes
127 )
128
129 #---------- default config ----------
130
131 defcfg = '''
132 [COMMON]
133 max_batch_down = 65536
134 max_queue_time = 10
135 target_requests_outstanding = 3
136 http_timeout = 30
137 http_timeout_grace = 5
138 max_requests_outstanding = 6
139 max_batch_up = 4000
140 http_retry = 5
141 port = 80
142 vroutes = ''
143 ifname_client = hippo%%d
144 ifname_server = shippo%%d
145 max_clock_skew = 300
146
147 #[server] or [<client>] overrides
148 ipif = userv root ipif %(local)s,%(peer)s,%(mtu)s,slip,%(ifname)s %(rnets)s
149
150 # relating to virtual network
151 mtu = 1500
152
153 # addrs = 127.0.0.1 ::1
154 # url
155
156 # relating to virtual network
157 vvnetwork = 172.24.230.192
158 # vnetwork = <prefix>/<len>
159 # vaddr = <ipaddr>
160 # vrelay = <ipaddr>
161
162
163 # [<client-ip4-or-ipv6-address>]
164 # secret = <secret> # used by both, must match
165
166 [LIMIT]
167 max_batch_down = 262144
168 max_queue_time = 121
169 http_timeout = 121
170 target_requests_outstanding = 10
171 '''
172
173 # these need to be defined here so that they can be imported by import *
174 cfg = ConfigParser(strict=False)
175 optparser = OptionParser()
176
177 _mimetrans = bytes.maketrans(b'-'+slip.esc, slip.esc+b'-')
178 def mime_translate(s):
179 # SLIP-encoded packets cannot contain ESC ESC.
180 # Swap `-' and ESC. The result cannot contain `--'
181 return s.translate(_mimetrans)
182
183 class ConfigResults:
184 def __init__(self):
185 pass
186 def __repr__(self):
187 return 'ConfigResults('+repr(self.__dict__)+')'
188
189 def log_discard(packet, iface, saddr, daddr, why):
190 log_debug(DBG.DROP,
191 'discarded packet [%s] %s -> %s: %s' % (iface, saddr, daddr, why),
192 d=packet)
193
194 #---------- packet parsing ----------
195
196 def packet_addrs(packet):
197 version = packet[0] >> 4
198 if version == 4:
199 addrlen = 4
200 saddroff = 3*4
201 factory = ipaddress.IPv4Address
202 elif version == 6:
203 addrlen = 16
204 saddroff = 2*4
205 factory = ipaddress.IPv6Address
206 else:
207 raise ValueError('unsupported IP version %d' % version)
208 saddr = factory(packet[ saddroff : saddroff + addrlen ])
209 daddr = factory(packet[ saddroff + addrlen : saddroff + addrlen*2 ])
210 return (saddr, daddr)
211
212 #---------- address handling ----------
213
214 def ipaddr(input):
215 try:
216 r = ipaddress.IPv4Address(input)
217 except AddressValueError:
218 r = ipaddress.IPv6Address(input)
219 return r
220
221 def ipnetwork(input):
222 try:
223 r = ipaddress.IPv4Network(input)
224 except NetworkValueError:
225 r = ipaddress.IPv6Network(input)
226 return r
227
228 #---------- ipif (SLIP) subprocess ----------
229
230 class SlipStreamDecoder():
231 def __init__(self, desc, on_packet):
232 self._buffer = b''
233 self._on_packet = on_packet
234 self._desc = desc
235 self._log('__init__')
236
237 def _log(self, msg, **kwargs):
238 log_debug(DBG.SLIP_FULL, 'slip %s: %s' % (self._desc, msg), **kwargs)
239
240 def inputdata(self, data):
241 self._log('inputdata', d=data)
242 data = self._buffer + data
243 self._buffer = b''
244 packets = slip.decode(data, True)
245 self._buffer = packets.pop()
246 for packet in packets:
247 self._maybe_packet(packet)
248 self._log('bufremain', d=self._buffer)
249
250 def _maybe_packet(self, packet):
251 self._log('maybepacket', d=packet)
252 if len(packet):
253 self._on_packet(packet)
254
255 def flush(self):
256 self._log('flush')
257 data = self._buffer
258 self._buffer = b''
259 packets = slip.decode(data)
260 assert(len(packets) == 1)
261 self._maybe_packet(packets[0])
262
263 class _IpifProcessProtocol(twisted.internet.protocol.ProcessProtocol):
264 def __init__(self, router):
265 self._router = router
266 self._decoder = SlipStreamDecoder('ipif', self.slip_on_packet)
267 def connectionMade(self): pass
268 def outReceived(self, data):
269 self._decoder.inputdata(data)
270 def slip_on_packet(self, packet):
271 (saddr, daddr) = packet_addrs(packet)
272 if saddr.is_link_local or daddr.is_link_local:
273 log_discard(packet, 'ipif', saddr, daddr, 'link-local')
274 return
275 self._router(packet, saddr, daddr)
276 def processEnded(self, status):
277 status.raiseException()
278
279 def start_ipif(command, router):
280 ipif = _IpifProcessProtocol(router)
281 reactor.spawnProcess(ipif,
282 '/bin/sh',['sh','-xc', command],
283 childFDs={0:'w', 1:'r', 2:2},
284 env=None)
285 return ipif
286
287 def queue_inbound(ipif, packet):
288 log_debug(DBG.FLOW, "queue_inbound", d=packet)
289 ipif.transport.write(slip.delimiter)
290 ipif.transport.write(slip.encode(packet))
291 ipif.transport.write(slip.delimiter)
292
293 #---------- packet queue ----------
294
295 class PacketQueue():
296 def __init__(self, desc, max_queue_time):
297 self._desc = desc
298 assert(desc + '')
299 self._max_queue_time = max_queue_time
300 self._pq = collections.deque() # packets
301
302 def _log(self, dflag, msg, **kwargs):
303 log_debug(dflag, self._desc+' pq: '+msg, **kwargs)
304
305 def append(self, packet):
306 self._log(DBG.QUEUE, 'append', d=packet)
307 self._pq.append((time.monotonic(), packet))
308
309 def nonempty(self):
310 self._log(DBG.QUEUE, 'nonempty ?')
311 while True:
312 try: (queuetime, packet) = self._pq[0]
313 except IndexError:
314 self._log(DBG.QUEUE, 'nonempty ? empty.')
315 return False
316
317 age = time.monotonic() - queuetime
318 if age > self._max_queue_time:
319 # strip old packets off the front
320 self._log(DBG.QUEUE, 'dropping (old)', d=packet)
321 self._pq.popleft()
322 continue
323
324 self._log(DBG.QUEUE, 'nonempty ? nonempty.')
325 return True
326
327 def process(self, sizequery, moredata, max_batch):
328 # sizequery() should return size of batch so far
329 # moredata(s) should add s to batch
330 self._log(DBG.QUEUE, 'process...')
331 while True:
332 try: (dummy, packet) = self._pq[0]
333 except IndexError:
334 self._log(DBG.QUEUE, 'process... empty')
335 break
336
337 self._log(DBG.QUEUE_CTRL, 'process... packet', d=packet)
338
339 encoded = slip.encode(packet)
340 sofar = sizequery()
341
342 self._log(DBG.QUEUE_CTRL,
343 'process... (sofar=%d, max=%d) encoded' % (sofar, max_batch),
344 d=encoded)
345
346 if sofar > 0:
347 if sofar + len(slip.delimiter) + len(encoded) > max_batch:
348 self._log(DBG.QUEUE_CTRL, 'process... overflow')
349 break
350 moredata(slip.delimiter)
351
352 moredata(encoded)
353 self._pq.popleft()
354
355 #---------- error handling ----------
356
357 _crashing = False
358
359 def crash(err):
360 global _crashing
361 _crashing = True
362 print('========== CRASH ==========', err,
363 '===========================', file=sys.stderr)
364 try: reactor.stop()
365 except twisted.internet.error.ReactorNotRunning: pass
366
367 def crash_on_defer(defer):
368 defer.addErrback(lambda err: crash(err))
369
370 def crash_on_critical(event):
371 if event.get('log_level') >= LogLevel.critical:
372 crash(twisted.logger.formatEvent(event))
373
374 #---------- authentication tokens ----------
375
376 _authtoken_digest = hashlib.sha256
377
378 def _authtoken_time():
379 return int(time.time())
380
381 def _authtoken_hmac(secret, hextime):
382 return hmac.new(secret, hextime, _authtoken_digest).digest()
383
384 def authtoken_make(secret):
385 hextime = ('%x' % _authtoken_time()).encode('ascii')
386 mac = _authtoken_hmac(secret, hextime)
387 return hextime + b' ' + base64.b64encode(mac)
388
389 def authtoken_check(secret, token, maxskew):
390 (hextime, theirmac64) = token.split(b' ')
391 now = _authtoken_time()
392 then = int(hextime, 16)
393 skew = then - now;
394 if (abs(skew) > maxskew):
395 raise ValueError('too much clock skew (client %ds ahead)' % skew)
396 theirmac = base64.b64decode(theirmac64)
397 ourmac = _authtoken_hmac(secret, hextime)
398 if not hmac.compare_digest(theirmac, ourmac):
399 raise ValueError('invalid token (wrong secret?)')
400 pass
401
402 #---------- config processing ----------
403
404 def _cfg_process_putatives():
405 servers = { }
406 clients = { }
407 # maps from abstract object to canonical name for cs's
408
409 def putative(cmap, abstract, canoncs):
410 try:
411 current_canoncs = cmap[abstract]
412 except KeyError:
413 pass
414 else:
415 assert(current_canoncs == canoncs)
416 cmap[abstract] = canoncs
417
418 server_pat = r'[-.0-9A-Za-z]+'
419 client_pat = r'[.:0-9a-f]+'
420 server_re = regexp.compile(server_pat)
421 serverclient_re = regexp.compile(
422 server_pat + r' ' + '(?:' + client_pat + '|LIMIT)')
423
424 for cs in cfg.sections():
425 def dbg(m):
426 log_debug_config('putatives: section [%s] %s' % (cs, m))
427
428 def log_ignore(why):
429 dbg('X ignore: %s' % (why))
430 print('warning: ignoring config section [%s] (%s)' % (cs, why),
431 file=sys.stderr)
432
433 if cs == 'LIMIT' or cs == 'COMMON':
434 # plan A "[LIMIT]" or "[COMMON]"
435 dbg('A ignore')
436 continue
437
438 try:
439 # plan B "[<client>]" part 1
440 ci = ipaddr(cs)
441 except AddressValueError:
442
443 if server_re.fullmatch(cs):
444 # plan C "[<servername>]"
445 dbg('C <server>')
446 putative(servers, cs, cs)
447 continue
448
449 if serverclient_re.fullmatch(cs):
450 # plan D "[<servername> <client>]" part 1
451 (pss,pcs) = cs.split(' ')
452
453 if pcs == 'LIMIT':
454 # plan E "[<servername> LIMIT]"
455 dbg('E <server> LIMIT')
456 continue
457
458 try:
459 # plan D "[<servername> <client>]" part 2
460 ci = ipaddr(pcs)
461 except AddressValueError:
462 # plan F branch 1 "[<some thing we do not understand>]"
463 log_ignore('bad-addr')
464 continue
465
466 else: # no AddressValueError
467 # plan D "[<servername> <client>]" part 3
468 dbg('D <server> <client>')
469 putative(clients, ci, pcs)
470 putative(servers, pss, pss)
471 continue
472 else:
473 # plan F branch 2 "[<some thing we do not understand>]"
474 log_ignore('nomatch '+ repr(serverclient_re))
475
476 else: # no AddressValueError
477 # plan B "[<client>" part 2
478 dbg('B <client>')
479 putative(clients, ci, cs)
480 continue
481
482 return (servers, clients)
483
484 def cfg_process_general(c, ss):
485 c.mtu = cfg1getint(ss, 'mtu')
486
487 def cfg_process_saddrs(c, ss):
488 class ServerAddr():
489 def __init__(self, port, addrspec):
490 self.port = port
491 # also self.addr
492 try:
493 self.addr = ipaddress.IPv4Address(addrspec)
494 self._endpointfactory = twisted.internet.endpoints.TCP4ServerEndpoint
495 self._inurl = b'%s'
496 except AddressValueError:
497 self.addr = ipaddress.IPv6Address(addrspec)
498 self._endpointfactory = twisted.internet.endpoints.TCP6ServerEndpoint
499 self._inurl = b'[%s]'
500 def make_endpoint(self):
501 return self._endpointfactory(reactor, self.port,
502 interface= '%s' % self.addr)
503 def url(self):
504 url = b'http://' + (self._inurl % str(self.addr).encode('ascii'))
505 if self.port != 80: url += b':%d' % self.port
506 url += b'/'
507 return url
508 def __repr__(self):
509 return 'ServerAddr'+repr((self.port,self.addr))
510
511 c.port = cfg1getint(ss,'port')
512 c.saddrs = [ ]
513 for addrspec in cfg1get(ss, 'addrs').split():
514 sa = ServerAddr(c.port, addrspec)
515 c.saddrs.append(sa)
516
517 def cfg_process_vnetwork(c, ss):
518 c.vnetwork = ipnetwork(cfg1get(ss,'vnetwork'))
519 if c.vnetwork.num_addresses < 3 + 2:
520 raise ValueError('vnetwork needs at least 2^3 addresses')
521
522 def cfg_process_vaddr(c, ss):
523 try:
524 c.vaddr = cfg1get(ss,'vaddr')
525 except NoOptionError:
526 cfg_process_vnetwork(c, ss)
527 c.vaddr = next(c.vnetwork.hosts())
528
529 def cfg_search_section(key,sections):
530 for section in sections:
531 if cfg.has_option(section, key):
532 return section
533 raise NoOptionError(key, repr(sections))
534
535 def cfg_get_raw(*args, **kwargs):
536 # for passing to cfg_search
537 return cfg.get(*args, raw=True, **kwargs)
538
539 def cfg_search(getter,key,sections):
540 section = cfg_search_section(key,sections)
541 return getter(section, key)
542
543 def cfg1get(section,key, getter=cfg.get,**kwargs):
544 section = cfg_search_section(key,[section,'COMMON'])
545 return getter(section,key,**kwargs)
546
547 def cfg1getint(section,key, **kwargs):
548 return cfg1get(section,key, getter=cfg.getint,**kwargs);
549
550 def cfg_process_client_limited(cc,ss,sections,key):
551 val = cfg_search(cfg1getint, key, sections)
552 lim = cfg_search(cfg1getint, key, ['%s LIMIT' % ss, 'LIMIT'])
553 cc.__dict__[key] = min(val,lim)
554
555 def cfg_process_client_common(cc,ss,cs,ci):
556 # returns sections to search in, iff secret is defined, otherwise None
557 cc.ci = ci
558
559 sections = ['%s %s' % (ss,cs),
560 cs,
561 ss,
562 'COMMON']
563
564 try: pwsection = cfg_search_section('secret', sections)
565 except NoOptionError: return None
566
567 pw = cfg1get(pwsection, 'secret')
568 cc.secret = pw.encode('utf-8')
569
570 cfg_process_client_limited(cc,ss,sections,'target_requests_outstanding')
571 cfg_process_client_limited(cc,ss,sections,'http_timeout')
572
573 return sections
574
575 def cfg_process_ipif(c, sections, varmap):
576 for d, s in varmap:
577 try: v = getattr(c, s)
578 except AttributeError: continue
579 setattr(c, d, v)
580 for d in ('mtu',):
581 v = cfg_search(cfg.get, d, sections)
582 setattr(c, d, v)
583
584 #print('CFGIPIF',repr((varmap, sections, c.__dict__)),file=sys.stderr)
585
586 section = cfg_search_section('ipif', sections)
587 c.ipif_command = cfg1get(section,'ipif', vars=c.__dict__)
588
589 #---------- startup ----------
590
591 def log_debug_config(m):
592 if not DBG.CONFIG in debug_set: return
593 print('DBG.CONFIG:', m)
594
595 def common_startup(process_cfg):
596 # calls process_cfg(putative_clients, putative_servers)
597
598 # ConfigParser hates #-comments after values
599 trailingcomments_re = regexp.compile(r'#.*')
600 cfg.read_string(trailingcomments_re.sub('', defcfg))
601 need_defcfg = True
602
603 def readconfig(pathname, mandatory=True):
604 def log(m, p=pathname):
605 if not DBG.CONFIG in debug_set: return
606 log_debug_config('%s: %s' % (m, p))
607
608 try:
609 files = os.listdir(pathname)
610
611 except FileNotFoundError:
612 if mandatory: raise
613 log('skipped')
614 return
615
616 except NotADirectoryError:
617 cfg.read(pathname)
618 log('read file')
619 return
620
621 # is a directory
622 log('directory')
623 re = regexp.compile('[^-A-Za-z0-9_]')
624 for f in os.listdir(pathname):
625 if re.search(f): continue
626 subpath = pathname + '/' + f
627 try:
628 os.stat(subpath)
629 except FileNotFoundError:
630 log('entry skipped', subpath)
631 continue
632 cfg.read(subpath)
633 log('entry read', subpath)
634
635 def oc_config(od,os, value, op):
636 nonlocal need_defcfg
637 need_defcfg = False
638 readconfig(value)
639
640 def oc_extra_config(od,os, value, op):
641 readconfig(value)
642
643 def read_defconfig():
644 readconfig('/etc/hippotat/config.d', False)
645 readconfig('/etc/hippotat/secrets.d', False)
646 readconfig('/etc/hippotat/master.cfg', False)
647
648 def oc_defconfig(od,os, value, op):
649 nonlocal need_defcfg
650 need_defcfg = False
651 read_defconfig(value)
652
653 def dfs_less_detailed(dl):
654 return [df for df in DBG.iterconstants() if df <= dl]
655
656 def ds_default(od,os,dl,op):
657 global debug_set
658 debug_set.clear
659 debug_set |= set(dfs_less_detailed(debug_def_detail))
660
661 def ds_select(od,os, spec, op):
662 for it in spec.split(','):
663
664 if it.startswith('-'):
665 mutator = debug_set.discard
666 it = it[1:]
667 else:
668 mutator = debug_set.add
669
670 if it == '+':
671 dfs = DBG.iterconstants()
672
673 else:
674 if it.endswith('+'):
675 mapper = dfs_less_detailed
676 it = it[0:len(it)-1]
677 else:
678 mapper = lambda x: [x]
679
680 try:
681 dfspec = DBG.lookupByName(it)
682 except ValueError:
683 optparser.error('unknown debug flag %s in --debug-select' % it)
684
685 dfs = mapper(dfspec)
686
687 for df in dfs:
688 mutator(df)
689
690 optparser.add_option('-D', '--debug',
691 nargs=0,
692 action='callback',
693 help='enable default debug (to stdout)',
694 callback= ds_default)
695
696 optparser.add_option('--debug-select',
697 nargs=1,
698 type='string',
699 metavar='[-]DFLAG[+]|[-]+,...',
700 help=
701 '''enable (`-': disable) each specified DFLAG;
702 `+': do same for all "more interesting" DFLAGSs;
703 just `+': all DFLAGs.
704 DFLAGS: ''' + ' '.join([df.name for df in DBG.iterconstants()]),
705 action='callback',
706 callback= ds_select)
707
708 optparser.add_option('-c', '--config',
709 nargs=1,
710 type='string',
711 metavar='CONFIGFILE',
712 dest='configfile',
713 action='callback',
714 callback= oc_config)
715
716 optparser.add_option('--extra-config',
717 nargs=1,
718 type='string',
719 metavar='CONFIGFILE',
720 dest='configfile',
721 action='callback',
722 callback= oc_extra_config)
723
724 optparser.add_option('--default-config',
725 action='callback',
726 callback= oc_defconfig)
727
728 (opts, args) = optparser.parse_args()
729 if len(args): optparser.error('no non-option arguments please')
730
731 if need_defcfg:
732 read_defconfig()
733
734 try:
735 (pss, pcs) = _cfg_process_putatives()
736 process_cfg(opts, pss, pcs)
737 except (configparser.Error, ValueError):
738 traceback.print_exc(file=sys.stderr)
739 print('\nInvalid configuration, giving up.', file=sys.stderr)
740 sys.exit(12)
741
742
743 #print('X', debug_set, file=sys.stderr)
744
745 log_formatter = twisted.logger.formatEventAsClassicLogText
746 stdout_obs = twisted.logger.FileLogObserver(sys.stdout, log_formatter)
747 stderr_obs = twisted.logger.FileLogObserver(sys.stderr, log_formatter)
748 pred = twisted.logger.LogLevelFilterPredicate(LogLevel.error)
749 stdsomething_obs = twisted.logger.FilteringLogObserver(
750 stderr_obs, [pred], stdout_obs
751 )
752 global file_log_observer
753 file_log_observer = twisted.logger.FilteringLogObserver(
754 stdsomething_obs, [LogNotBoringTwisted()]
755 )
756 #log_observer = stdsomething_obs
757 twisted.logger.globalLogBeginner.beginLoggingTo(
758 [ file_log_observer, crash_on_critical ]
759 )
760
761 def common_run():
762 log_debug(DBG.INIT, 'entering reactor')
763 if not _crashing: reactor.run()
764 print('ENDED', file=sys.stderr)
765 sys.exit(16)