blob: ed4522c11c04c3a6b5a1598c5e3eb69a447f8477 [file] [log] [blame]
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +02001#!/usr/bin/python3
2# -*- mode: python-mode; py-indent-tabs-mode: nil -*-
3"""
4/*
5 * Copyright (C) 2018 sysmocom s.f.m.c. GmbH
6 *
7 * All Rights Reserved
8 *
9 * This program is free software; you can redistribute it and/or modify
10 * it under the terms of the GNU General Public License as published by
11 * the Free Software Foundation; either version 3 of the License, or
12 * (at your option) any later version.
13 *
14 * This program is distributed in the hope that it will be useful,
15 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17 * GNU General Public License for more details.
18 *
19 * You should have received a copy of the GNU General Public License along
20 * with this program; if not, write to the Free Software Foundation, Inc.,
21 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
22 */
23"""
24
25__version__ = "0.0.1" # bump this on every non-trivial change
26
27from twisted.internet import defer, reactor
28from osmopy.twisted_ipa import CTRL, IPAFactory, __version__ as twisted_ipa_version
29from osmopy.osmo_ipa import Ctrl
30from treq import post, collect
31from functools import partial
32from distutils.version import StrictVersion as V # FIXME: use NormalizedVersion from PEP-386 when available
33import argparse, datetime, signal, sys, os, logging, logging.handlers
34import hashlib
35import json
36import configparser
37
38# we don't support older versions of TwistedIPA module
39assert V(twisted_ipa_version) > V('0.4')
40
41# keys from OpenBSC openbsc/src/libbsc/bsc_rf_ctrl.c, values CGI-specific
42oper = { 'inoperational' : 0, 'operational' : 1 }
43admin = { 'locked' : 0, 'unlocked' : 1 }
44policy = { 'off' : 0, 'on' : 1, 'grace' : 2, 'unknown' : 3 }
45
46# keys from OpenBSC openbsc/src/libbsc/bsc_vty.c
47fix = { 'invalid' : 0, 'fix2d' : 1, 'fix3d' : 1 } # CGI server treats it as boolean but expects int
48
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +020049@defer.inlineCallbacks
50def handle_reply(f, log, resp):
51 """
52 Reply handler: process raw CGI server response, function f to run for each command
53 """
54 #log.debug('HANDLE_REPLY: code=%r' % (resp.code))
55 #for key,val in resp.headers.getAllRawHeaders():
56 # log.debug('HANDLE_REPLY: key=%r val=%r' % (key, val))
57 if resp.code != 200:
58 resp_body = yield resp.text()
59 log.critical('Received HTTP response %d: %s' % (resp.code, resp_body))
60 return
61
62 parsed = yield resp.json()
63 #log.debug("RESPONSE: %r" % (parsed))
64 bsc_id = parsed.get('commands')[0].split()[0].split('.')[3] # we expect 1st command to have net.0.bsc.666.bts.2.trx.1 location prefix format
65 log.info("Received CGI response for BSC %s with %d commands, error status: %s" % (bsc_id, len(parsed.get('commands')), parsed.get('error')))
66 log.debug("BSC %s commands: %r" % (bsc_id, parsed.get('commands')))
67 for t in parsed.get('commands'): # Process commands format
68 (_, m) = Ctrl().cmd(*t.split())
69 f(m)
70
71def gen_hash(params, skey):
72 input = ''
73 for key in ['time_stamp','position_validity','admin_status','policy_status']:
74 input += str(params.get(key))
75 input += skey
76 for key in ['bsc_id','lat','lon','position_validity']:
77 input += str(params.get(key))
78 m = hashlib.md5()
79 m.update(input.encode('utf-8'))
80 res = m.hexdigest()
81 #print('HASH: \nparams="%r"\ninput="%s" \nres="%s"' %(params, input, res))
82 return res
83
84class Trap(CTRL):
85 """
86 TRAP handler (agnostic to factory's client object)
87 """
88 def ctrl_TRAP(self, data, op_id, v):
89 """
90 Parse CTRL TRAP and dispatch to appropriate handler after normalization
91 """
Maxf2199442018-11-26 14:30:00 +010092 self.factory.log.debug('TRAP %s' % v)
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +020093 (l, r) = v.split()
94 loc = l.split('.')
95 t_type = loc[-1]
96 p = partial(lambda a, i: a[i] if len(a) > i else None, loc) # parse helper
Max9fa695d2018-11-26 14:37:36 +010097 method = getattr(self, 'handle_' + t_type.replace('-', ''), lambda *_: "Unhandled %s trap" % t_type)
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +020098 method(p(1), p(3), p(5), p(7), r) # we expect net.0.bsc.666.bts.2.trx.1 format for trap prefix
99
100 def ctrl_SET_REPLY(self, data, _, v):
101 """
102 Debug log for replies to our commands
103 """
104 self.factory.log.debug('SET REPLY %s' % v)
105
106 def ctrl_ERROR(self, data, op_id, v):
107 """
108 We want to know if smth went wrong
109 """
110 self.factory.log.debug('CTRL ERROR [%s] %s' % (op_id, v))
111
112 def connectionMade(self):
113 """
114 Logging wrapper, calling super() is necessary not to break reconnection logic
115 """
116 self.factory.log.info("Connected to CTRL@%s:%d" % (self.factory.host, self.factory.port))
117 super(CTRL, self).connectionMade()
118
119 @defer.inlineCallbacks
120 def handle_locationstate(self, net, bsc, bts, trx, data):
121 """
122 Handle location-state TRAP: parse trap content, build CGI Request and use treq's routines to post it while setting up async handlers
123 """
124 (ts, fx, lat, lon, height, opr, adm, pol, mcc, mnc) = data.split(',')
125 tstamp = datetime.datetime.fromtimestamp(float(ts)).isoformat()
126 self.factory.log.debug('location-state@%s.%s.%s.%s (%s) [%s/%s] => %s' % (net, bsc, bts, trx, tstamp, mcc, mnc, data))
127 params = {'bsc_id': bsc, 'lon': lon, 'lat': lat, 'position_validity': fix.get(fx, 0), 'time_stamp': tstamp, 'oper_status': oper.get(opr, 2), 'admin_status': admin.get(adm, 2), 'policy_status': policy.get(pol, 3) }
128 params['h'] = gen_hash(params, self.factory.secret_key)
129 d = post(self.factory.location, None, params=params)
130 d.addCallback(partial(handle_reply, self.transport.write, self.factory.log)) # treq's collect helper is handy to get all reply content at once using closure on ctx
Maxc16fa3c2018-11-22 18:45:21 +0100131 d.addErrback(lambda e, bsc: self.factory.log.critical("HTTP POST error %s while trying to register BSC %s on %s" % (e, bsc, self.factory.location)), bsc) # handle HTTP errors
Pau Espin Pedrol10fbb402018-07-11 14:05:13 +0200132 # Ensure that we run only limited number of requests in parallel:
133 yield self.factory.semaphore.acquire()
134 yield d # we end up here only if semaphore is available which means it's ok to fire the request without exceeding the limit
135 self.factory.semaphore.release()
136
137 def handle_notificationrejectionv1(self, net, bsc, bts, trx, data):
138 """
139 Handle notification-rejection-v1 TRAP: just an example to show how more message types can be handled
140 """
141 self.factory.log.debug('notification-rejection-v1@bsc-id %s => %s' % (bsc, data))
142
143
144class TrapFactory(IPAFactory):
145 """
146 Store CGI information so TRAP handler can use it for requests
147 """
148 location = None
149 log = None
150 semaphore = None
151 client = None
152 host = None
153 port = None
154 secret_key = None
155 def __init__(self, host, port, proto, semaphore, log, location, secret_key):
156 self.host = host # for logging only,
157 self.port = port # seems to be no way to get it from ReconnectingClientFactory
158 self.log = log
159 self.semaphore = semaphore
160 self.location = location
161 self.secret_key = secret_key
162 level = self.log.getEffectiveLevel()
163 self.log.setLevel(logging.WARNING) # we do not need excessive debug from lower levels
164 super(TrapFactory, self).__init__(proto, self.log)
165 self.log.setLevel(level)
166 self.log.debug("Using IPA %s, CGI server: %s" % (Ctrl.version, self.location))
167
168
169def reloader(path, script, log, dbg1, dbg2, signum, _):
170 """
171 Signal handler: we have to use execl() because twisted's reactor is not restartable due to some bug in twisted implementation
172 """
173 log.info("Received Signal %d - restarting..." % signum)
174 if signum == signal.SIGUSR1 and dbg1 not in sys.argv and dbg2 not in sys.argv:
175 sys.argv.append(dbg1) # enforce debug
176 if signum == signal.SIGUSR2 and (dbg1 in sys.argv or dbg2 in sys.argv): # disable debug
177 if dbg1 in sys.argv:
178 sys.argv.remove(dbg1)
179 if dbg2 in sys.argv:
180 sys.argv.remove(dbg2)
181 os.execl(path, script, *sys.argv[1:])
182
183
184if __name__ == '__main__':
185 p = argparse.ArgumentParser(description='Proxy between given GCI service and Osmocom CTRL protocol.')
186 p.add_argument('-v', '--version', action='version', version=("%(prog)s v" + __version__))
187 p.add_argument('-a', '--addr-ctrl', default='localhost', help="Adress to use for CTRL interface, defaults to localhost")
188 p.add_argument('-p', '--port-ctrl', type=int, default=4250, help="Port to use for CTRL interface, defaults to 4250")
189 p.add_argument('-n', '--num-max-conn', type=int, default=5, help="Max number of concurrent HTTP requests to CGI server")
190 p.add_argument('-d', '--debug', action='store_true', help="Enable debug log")
191 p.add_argument('-o', '--output', action='store_true', help="Log to STDOUT in addition to SYSLOG")
192 p.add_argument('-l', '--location', help="Location URL of the CGI server")
193 p.add_argument('-s', '--secret-key', help="Secret key used to generate verification token")
194 p.add_argument('-c', '--config-file', help="Path Config file. Cmd line args override values in config file")
195 args = p.parse_args()
196
197 log = logging.getLogger('CTRL2CGI')
198 if args.debug:
199 log.setLevel(logging.DEBUG)
200 else:
201 log.setLevel(logging.INFO)
202 log.addHandler(logging.handlers.SysLogHandler('/dev/log'))
203 if args.output:
204 log.addHandler(logging.StreamHandler(sys.stdout))
205
206 reboot = partial(reloader, os.path.abspath(__file__), os.path.basename(__file__), log, '-d', '--debug') # keep in sync with add_argument() call above
207 signal.signal(signal.SIGHUP, reboot)
208 signal.signal(signal.SIGQUIT, reboot)
209 signal.signal(signal.SIGUSR1, reboot) # restart and enabled debug output
210 signal.signal(signal.SIGUSR2, reboot) # restart and disable debug output
211
212 location_cfgfile = None
213 secret_key_cfgfile = None
214 port_ctrl_cfgfile = None
215 addr_ctrl_cfgfile = None
216 num_max_conn_cfgfile = None
217 if args.config_file:
218 config = configparser.ConfigParser()
219 config.read(args.config_file)
220 if 'main' in config:
221 location_cfgfile = config['main'].get('location', None)
222 secret_key_cfgfile = config['main'].get('secret_key', None)
223 addr_ctrl_cfgfile = config['main'].get('addr_ctrl', None)
224 port_ctrl_cfgfile = config['main'].get('port_ctrl', None)
225 num_max_conn_cfgfile = config['main'].get('num_max_conn', None)
226 location = args.location if args.location is not None else location_cfgfile
227 secret_key = args.secret_key if args.secret_key is not None else secret_key_cfgfile
228 addr_ctrl = args.addr_ctrl if args.addr_ctrl is not None else addr_ctrl_cfgfile
229 port_ctrl = args.port_ctrl if args.port_ctrl is not None else port_ctrl_cfgfile
230 num_max_conn = args.num_max_conn if args.num_max_conn is not None else num_max_conn_cfgfile
231
232 log.info("CGI proxy %s starting with PID %d ..." % (__version__, os.getpid()))
233 reactor.connectTCP(addr_ctrl, port_ctrl, TrapFactory(addr_ctrl, port_ctrl, Trap, defer.DeferredSemaphore(num_max_conn), log, location, secret_key))
234 reactor.run()