chiark / gitweb /
ensure mtu is in the ipif substitution set
[hippotat.git] / 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)