chiark / gitweb /
replace plaintext secret transmission with time-limited hmac-based bearer tokens
[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
581   #print('CFGIPIF',repr((varmap, sections, c.__dict__)),file=sys.stderr)
582
583   section = cfg_search_section('ipif', sections)
584   c.ipif_command = cfg1get(section,'ipif', vars=c.__dict__)
585
586 #---------- startup ----------
587
588 def log_debug_config(m):
589   if not DBG.CONFIG in debug_set: return
590   print('DBG.CONFIG:', m)
591
592 def common_startup(process_cfg):
593   # calls process_cfg(putative_clients, putative_servers)
594
595   # ConfigParser hates #-comments after values
596   trailingcomments_re = regexp.compile(r'#.*')
597   cfg.read_string(trailingcomments_re.sub('', defcfg))
598   need_defcfg = True
599
600   def readconfig(pathname, mandatory=True):
601     def log(m, p=pathname):
602       if not DBG.CONFIG in debug_set: return
603       log_debug_config('%s: %s' % (m, p))
604
605     try:
606       files = os.listdir(pathname)
607
608     except FileNotFoundError:
609       if mandatory: raise
610       log('skipped')
611       return
612
613     except NotADirectoryError:
614       cfg.read(pathname)
615       log('read file')
616       return
617
618     # is a directory
619     log('directory')
620     re = regexp.compile('[^-A-Za-z0-9_]')
621     for f in os.listdir(pathname):
622       if re.search(f): continue
623       subpath = pathname + '/' + f
624       try:
625         os.stat(subpath)
626       except FileNotFoundError:
627         log('entry skipped', subpath)
628         continue
629       cfg.read(subpath)
630       log('entry read', subpath)
631       
632   def oc_config(od,os, value, op):
633     nonlocal need_defcfg
634     need_defcfg = False
635     readconfig(value)
636
637   def oc_extra_config(od,os, value, op):
638     readconfig(value)
639
640   def read_defconfig():
641     readconfig('/etc/hippotat/config.d', False)
642     readconfig('/etc/hippotat/secrets.d', False)
643     readconfig('/etc/hippotat/master.cfg',   False)
644
645   def oc_defconfig(od,os, value, op):
646     nonlocal need_defcfg
647     need_defcfg = False
648     read_defconfig(value)
649
650   def dfs_less_detailed(dl):
651     return [df for df in DBG.iterconstants() if df <= dl]
652
653   def ds_default(od,os,dl,op):
654     global debug_set
655     debug_set.clear
656     debug_set |= set(dfs_less_detailed(debug_def_detail))
657
658   def ds_select(od,os, spec, op):
659     for it in spec.split(','):
660
661       if it.startswith('-'):
662         mutator = debug_set.discard
663         it = it[1:]
664       else:
665         mutator = debug_set.add
666
667       if it == '+':
668         dfs = DBG.iterconstants()
669
670       else:
671         if it.endswith('+'):
672           mapper = dfs_less_detailed
673           it = it[0:len(it)-1]
674         else:
675           mapper = lambda x: [x]
676
677           try:
678             dfspec = DBG.lookupByName(it)
679           except ValueError:
680             optparser.error('unknown debug flag %s in --debug-select' % it)
681
682         dfs = mapper(dfspec)
683
684       for df in dfs:
685         mutator(df)
686
687   optparser.add_option('-D', '--debug',
688                        nargs=0,
689                        action='callback',
690                        help='enable default debug (to stdout)',
691                        callback= ds_default)
692
693   optparser.add_option('--debug-select',
694                        nargs=1,
695                        type='string',
696                        metavar='[-]DFLAG[+]|[-]+,...',
697                        help=
698 '''enable (`-': disable) each specified DFLAG;
699 `+': do same for all "more interesting" DFLAGSs;
700 just `+': all DFLAGs.
701   DFLAGS: ''' + ' '.join([df.name for df in DBG.iterconstants()]),
702                        action='callback',
703                        callback= ds_select)
704
705   optparser.add_option('-c', '--config',
706                        nargs=1,
707                        type='string',
708                        metavar='CONFIGFILE',
709                        dest='configfile',
710                        action='callback',
711                        callback= oc_config)
712
713   optparser.add_option('--extra-config',
714                        nargs=1,
715                        type='string',
716                        metavar='CONFIGFILE',
717                        dest='configfile',
718                        action='callback',
719                        callback= oc_extra_config)
720
721   optparser.add_option('--default-config',
722                        action='callback',
723                        callback= oc_defconfig)
724
725   (opts, args) = optparser.parse_args()
726   if len(args): optparser.error('no non-option arguments please')
727
728   if need_defcfg:
729     read_defconfig()
730
731   try:
732     (pss, pcs) = _cfg_process_putatives()
733     process_cfg(opts, pss, pcs)
734   except (configparser.Error, ValueError):
735     traceback.print_exc(file=sys.stderr)
736     print('\nInvalid configuration, giving up.', file=sys.stderr)
737     sys.exit(12)
738
739
740   #print('X', debug_set, file=sys.stderr)
741
742   log_formatter = twisted.logger.formatEventAsClassicLogText
743   stdout_obs = twisted.logger.FileLogObserver(sys.stdout, log_formatter)
744   stderr_obs = twisted.logger.FileLogObserver(sys.stderr, log_formatter)
745   pred = twisted.logger.LogLevelFilterPredicate(LogLevel.error)
746   stdsomething_obs = twisted.logger.FilteringLogObserver(
747     stderr_obs, [pred], stdout_obs
748   )
749   global file_log_observer
750   file_log_observer = twisted.logger.FilteringLogObserver(
751     stdsomething_obs, [LogNotBoringTwisted()]
752   )
753   #log_observer = stdsomething_obs
754   twisted.logger.globalLogBeginner.beginLoggingTo(
755     [ file_log_observer, crash_on_critical ]
756     )
757
758 def common_run():
759   log_debug(DBG.INIT, 'entering reactor')
760   if not _crashing: reactor.run()
761   print('ENDED', file=sys.stderr)
762   sys.exit(16)